@keyou007/agent-bot 0.1.24-alpha.2 → 0.1.24-alpha.3

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 (95) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +27 -5
  3. package/README.zh.md +26 -5
  4. package/dist/acp/AcpJsonRpcConnection.js +3 -1
  5. package/dist/acp/AcpJsonRpcConnection.js.map +1 -1
  6. package/dist/cli/SelfUpdateRunner.js.map +1 -1
  7. package/dist/cli/SelfUpdater.js +2 -0
  8. package/dist/cli/SelfUpdater.js.map +1 -1
  9. package/dist/codex/AppServerConnection.js +3 -1
  10. package/dist/codex/AppServerConnection.js.map +1 -1
  11. package/dist/codex/CodexEventMapper.js +28 -1
  12. package/dist/codex/CodexEventMapper.js.map +1 -1
  13. package/dist/codex/CodexRuntime.js +92 -0
  14. package/dist/codex/CodexRuntime.js.map +1 -1
  15. package/dist/commands/CommandRouter.js +5 -0
  16. package/dist/commands/CommandRouter.js.map +1 -1
  17. package/dist/feishu/CardRenderer.js +45 -23
  18. package/dist/feishu/CardRenderer.js.map +1 -1
  19. package/dist/feishu/FeishuConnector.js +2 -0
  20. package/dist/feishu/FeishuConnector.js.map +1 -1
  21. package/dist/feishu/FeishuMessageClient.js +44 -0
  22. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  23. package/dist/feishu/FeishuTurnPresenter.js +114 -37
  24. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  25. package/dist/feishu/RecentMessageContext.js +70 -0
  26. package/dist/feishu/RecentMessageContext.js.map +1 -0
  27. package/dist/index.js +62 -24
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-files/DiagramPreview.js +171 -0
  30. package/dist/local-files/DiagramPreview.js.map +1 -0
  31. package/dist/local-files/LocalFileViewerServer.js +141 -3
  32. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  33. package/dist/local-files/PreviewSyntaxHighlight.js +57 -0
  34. package/dist/local-files/PreviewSyntaxHighlight.js.map +1 -0
  35. package/dist/local-files/TurnPreviewPage.js +495 -60
  36. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  37. package/dist/presentation/OutboundRouter.js +18 -9
  38. package/dist/presentation/OutboundRouter.js.map +1 -1
  39. package/dist/presentation/TurnStateReducer.js +155 -6
  40. package/dist/presentation/TurnStateReducer.js.map +1 -1
  41. package/dist/presentation/turnReasoning.js +23 -0
  42. package/dist/presentation/turnReasoning.js.map +1 -0
  43. package/dist/proxy/ProxySessionController.js +460 -34
  44. package/dist/proxy/ProxySessionController.js.map +1 -1
  45. package/dist/state/StateStore.js +43 -2
  46. package/dist/state/StateStore.js.map +1 -1
  47. package/dist/state/TurnPreviewJournal.js +469 -0
  48. package/dist/state/TurnPreviewJournal.js.map +1 -0
  49. package/dist/state/migrations.js +10 -0
  50. package/dist/state/migrations.js.map +1 -1
  51. package/dist/updates/AutomaticUpdatePreparer.js +16 -7
  52. package/dist/updates/AutomaticUpdatePreparer.js.map +1 -1
  53. package/dist/updates/ManualUpdateController.js +102 -0
  54. package/dist/updates/ManualUpdateController.js.map +1 -0
  55. package/dist/updates/PublishedRelease.js +42 -4
  56. package/dist/updates/PublishedRelease.js.map +1 -1
  57. package/dist/utils/ProtocolLineWarning.js +15 -0
  58. package/dist/utils/ProtocolLineWarning.js.map +1 -0
  59. package/docs/technical-reference.md +21 -1
  60. package/docs/technical-reference.zh.md +21 -1
  61. package/npm-shrinkwrap.json +1401 -10
  62. package/package.json +3 -1
  63. package/skills/agent-bot/SKILL.md +8 -1
  64. package/src/acp/AcpJsonRpcConnection.ts +3 -1
  65. package/src/cli/SelfUpdateRunner.ts +1 -0
  66. package/src/cli/SelfUpdater.ts +2 -0
  67. package/src/codex/AppServerConnection.ts +3 -1
  68. package/src/codex/CodexEventMapper.ts +29 -1
  69. package/src/codex/CodexRuntime.ts +87 -0
  70. package/src/commands/CommandRouter.ts +4 -0
  71. package/src/commands/commandTypes.ts +1 -0
  72. package/src/feishu/CardRenderer.ts +43 -21
  73. package/src/feishu/FeishuConnector.ts +2 -0
  74. package/src/feishu/FeishuMessageClient.ts +44 -0
  75. package/src/feishu/FeishuTurnPresenter.ts +107 -31
  76. package/src/feishu/RecentMessageContext.ts +90 -0
  77. package/src/feishu/types.ts +5 -0
  78. package/src/index.ts +62 -22
  79. package/src/local-files/DiagramPreview.ts +173 -0
  80. package/src/local-files/LocalFileViewerServer.ts +129 -2
  81. package/src/local-files/PreviewSyntaxHighlight.ts +58 -0
  82. package/src/local-files/TurnPreviewPage.ts +535 -57
  83. package/src/presentation/OutboundRouter.ts +25 -7
  84. package/src/presentation/TurnStateReducer.ts +151 -7
  85. package/src/presentation/turnReasoning.ts +21 -0
  86. package/src/presentation/turnViewTypes.ts +19 -1
  87. package/src/proxy/ProxySessionController.ts +452 -24
  88. package/src/runtime/types.ts +21 -0
  89. package/src/state/StateStore.ts +45 -2
  90. package/src/state/TurnPreviewJournal.ts +398 -0
  91. package/src/state/migrations.ts +10 -0
  92. package/src/updates/AutomaticUpdatePreparer.ts +21 -7
  93. package/src/updates/ManualUpdateController.ts +116 -0
  94. package/src/updates/PublishedRelease.ts +49 -3
  95. package/src/utils/ProtocolLineWarning.ts +13 -0
@@ -1,8 +1,12 @@
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 type { ToolState } from "../runtime/types.js";
4
- import type { FileSummary, TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
7
+ import type { FileSummary, TurnActivity, TurnReasoningItem, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
5
8
  import { displayFilePath, displayToolCommand, formatShellCommandForDisplay, toolStatusIcon } from "../feishu/CardRenderer.js";
9
+ import { turnReasoningItems } from "../presentation/turnReasoning.js";
6
10
  import { isFileUrl, rewriteMarkdownFileLinks } from "./MarkdownFileLinks.js";
7
11
 
8
12
  const MARKDOWN = new MarkdownIt({
@@ -10,7 +14,9 @@ const MARKDOWN = new MarkdownIt({
10
14
  html: false,
11
15
  linkify: true,
12
16
  typographer: false,
17
+ highlight: highlightPreviewCode,
13
18
  });
19
+ enableDiagramFences(MARKDOWN);
14
20
  const validateLink = MARKDOWN.validateLink.bind(MARKDOWN);
15
21
  MARKDOWN.validateLink = (url) => isFileUrl(url) || validateLink(url);
16
22
  const defaultLinkOpen = MARKDOWN.renderer.rules.link_open
@@ -33,6 +39,7 @@ const PREVIEW_LABELS = {
33
39
  unknown: "未知",
34
40
  plan: "计划",
35
41
  error: "错误",
42
+ reasoning: "思考",
36
43
  finalAnswer: "最终回答",
37
44
  generating: "回答生成中",
38
45
  command: "命令",
@@ -47,7 +54,6 @@ const PREVIEW_LABELS = {
47
54
  tools: "个工具",
48
55
  turnTokens: "本轮",
49
56
  totalTokens: "总计",
50
- cachedTokens: "缓存命中",
51
57
  nonCachedTokens: "非缓存",
52
58
  compactionTokens: "压缩",
53
59
  compactionAfterTokens: "压缩后",
@@ -74,6 +80,7 @@ const PREVIEW_LABELS = {
74
80
  unknown: "Unknown",
75
81
  plan: "Plan",
76
82
  error: "Error",
83
+ reasoning: "Reasoning",
77
84
  finalAnswer: "Final answer",
78
85
  generating: "Generating answer",
79
86
  command: "Command",
@@ -88,7 +95,6 @@ const PREVIEW_LABELS = {
88
95
  tools: "tools",
89
96
  turnTokens: "Turn",
90
97
  totalTokens: "Total",
91
- cachedTokens: "Cache hit",
92
98
  nonCachedTokens: "Non-cached",
93
99
  compactionTokens: "Compaction",
94
100
  compactionAfterTokens: "After compaction",
@@ -128,13 +134,13 @@ export function detectTurnPreviewLanguage(
128
134
  return /^zh(?:[-_]|$)/iu.test(candidates[0]?.tag ?? "") ? "zh" : "en";
129
135
  }
130
136
 
131
- export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
137
+ export const TURN_PREVIEW_CLIENT_SCRIPT = DIAGRAM_PREVIEW_CLIENT_SCRIPT + `(() => {
132
138
  const eventsUrl = document.body.dataset.eventsUrl;
133
139
  const content = document.getElementById("turn-content");
134
140
  const metadata = document.getElementById("turn-metadata");
135
141
  const status = document.getElementById("turn-status");
136
142
  const live = document.getElementById("turn-live");
137
- if (!eventsUrl || !content || !metadata || !status || !live || typeof EventSource !== "function") return;
143
+ if (!eventsUrl || !content || !metadata || !status || !live) return;
138
144
 
139
145
  const isChinese = /^zh/i.test(document.documentElement.lang);
140
146
  const labels = {
@@ -143,9 +149,13 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
143
149
  waitingForUpdates: isChinese ? "等待更新" : "Waiting for updates",
144
150
  unavailable: isChinese ? "暂时不可用" : "Temporarily unavailable",
145
151
  reconnecting: isChinese ? "正在重连" : "Reconnecting",
152
+ loading: isChinese ? "正在加载…" : "Loading…",
153
+ loadFailed: isChinese ? "加载失败,请重试。" : "Could not load details.",
154
+ retry: isChinese ? "重试" : "Retry",
146
155
  };
147
156
 
148
157
  let source;
158
+ const terminalAtLoad = document.body.dataset.terminal === "true";
149
159
  const updateElapsed = () => {
150
160
  document.querySelectorAll("[data-live-elapsed]").forEach((element) => {
151
161
  const startedAt = Number(element.dataset.startedAt);
@@ -179,41 +189,284 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
179
189
  });
180
190
  };
181
191
  updateElapsed();
182
- setInterval(updateElapsed, 1000);
192
+ const clock = terminalAtLoad ? undefined : setInterval(updateElapsed, 1000);
183
193
 
184
- const replaceSnapshot = (update) => {
185
- const disclosures = new Map(Array.from(content.querySelectorAll("details[data-activity-id]"), (item) => [item.dataset.activityId, item.open]));
186
- const scrollPositions = new Map(Array.from(content.querySelectorAll("[data-scroll-id]"), (item) => [item.dataset.scrollId, [item.scrollLeft, item.scrollTop]]));
194
+ const nodeKey = (node) => {
195
+ if (node.nodeType !== 1) return undefined;
196
+ for (const attribute of ["data-preview-key", "data-activity", "data-activity-id", "data-scroll-id", "data-reasoning-field", "data-reasoning-part"]) {
197
+ if (node.hasAttribute(attribute)) return attribute + ":" + node.getAttribute(attribute);
198
+ }
199
+ if (node.tagName === "IMG") return "image:" + node.getAttribute("src");
200
+ // Keep Markdown image paragraphs/links anchored when text is inserted before them.
201
+ if (["P", "A", "FIGURE", "PICTURE"].includes(node.tagName)) {
202
+ const image = node.querySelector("img");
203
+ if (image) return node.tagName + ":image:" + image.getAttribute("src");
204
+ }
205
+ for (const name of ["markdown", "tool-command", "tool-image", "files", "tool-header", "tool-body", "tool-footer"]) {
206
+ if (node.classList.contains(name)) return node.tagName + ":" + name;
207
+ }
208
+ return undefined;
209
+ };
210
+ const compatible = (current, next) => current.nodeType === next.nodeType && current.nodeName === next.nodeName;
211
+ const patchNode = (current, next) => {
212
+ // Summary snapshots never own a detail body that has already been fetched.
213
+ if (current.nodeType === 1 && current.hasAttribute("data-detail-body") && next.hasAttribute("data-detail-body")) return;
214
+ if (current.isEqualNode(next)) return;
215
+ if (current.nodeType !== 1) {
216
+ if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
217
+ return;
218
+ }
219
+ if (current.hasAttribute("data-diagram") && next.hasAttribute("data-diagram")) {
220
+ const code = current.querySelector(".diagram-source code");
221
+ const nextCode = next.querySelector(".diagram-source code");
222
+ if (code && nextCode && code.textContent !== nextCode.textContent) {
223
+ code.textContent = nextCode.textContent;
224
+ window.agentBotDiagrams.refresh(current);
225
+ }
226
+ return;
227
+ }
228
+ const preserveOpen = current.tagName === "DETAILS";
229
+ for (const attribute of Array.from(current.attributes)) {
230
+ if (preserveOpen && attribute.name === "open") continue;
231
+ if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name);
232
+ }
233
+ for (const attribute of Array.from(next.attributes)) {
234
+ if (preserveOpen && attribute.name === "open") continue;
235
+ if (current.getAttribute(attribute.name) !== attribute.value) current.setAttribute(attribute.name, attribute.value);
236
+ }
237
+ patchChildren(current, next);
238
+ };
239
+ const patchChildren = (parent, next) => {
240
+ const nextChildren = Array.from(next.childNodes);
241
+ const nextKeys = new Set(nextChildren.map(nodeKey).filter((key) => key !== undefined));
242
+ const remaining = new Set(Array.from(parent.childNodes));
243
+ const keyed = new Map();
244
+ for (const child of remaining) {
245
+ const key = nodeKey(child);
246
+ if (key === undefined) continue;
247
+ if (!nextKeys.has(key)) {
248
+ child.remove();
249
+ remaining.delete(child);
250
+ continue;
251
+ }
252
+ const matches = keyed.get(key) || [];
253
+ matches.push(child);
254
+ keyed.set(key, matches);
255
+ }
256
+ let cursor = parent.firstChild;
257
+ for (const child of nextChildren) {
258
+ const key = nodeKey(child);
259
+ const matches = key === undefined ? undefined : keyed.get(key);
260
+ const current = key === undefined
261
+ ? cursor && nodeKey(cursor) === undefined && compatible(cursor, child) ? cursor : undefined
262
+ : matches?.find((candidate) => remaining.has(candidate) && compatible(candidate, child));
263
+ if (current) {
264
+ remaining.delete(current);
265
+ // Drop obsolete text/markup before a retained block without detaching that block.
266
+ while (cursor && cursor !== current && nodeKey(cursor) === undefined) {
267
+ const obsolete = cursor;
268
+ cursor = cursor.nextSibling;
269
+ remaining.delete(obsolete);
270
+ obsolete.remove();
271
+ }
272
+ if (current !== cursor) {
273
+ if (typeof parent.moveBefore === "function" && current.isConnected) parent.moveBefore(current, cursor);
274
+ else parent.insertBefore(current, cursor);
275
+ }
276
+ patchNode(current, child);
277
+ cursor = current.nextSibling;
278
+ } else {
279
+ const added = child.cloneNode(true);
280
+ parent.insertBefore(added, cursor);
281
+ }
282
+ }
283
+ for (const child of remaining) child.remove();
284
+ };
285
+ const patchMarkup = (element, markup) => {
286
+ const template = document.createElement("template");
287
+ template.innerHTML = markup;
288
+ patchChildren(element, template.content);
289
+ };
290
+ const detailStates = new WeakMap();
291
+ const detailState = (details) => {
292
+ if (!detailStates.has(details)) detailStates.set(details, {});
293
+ return detailStates.get(details);
294
+ };
295
+ const loadDetails = async (details, retry = false) => {
296
+ if (!details.open || !details.isConnected) return;
297
+ const state = detailState(details);
298
+ const revision = details.dataset.detailRevision;
299
+ if (state.loadedRevision === revision) state.loadedFor = revision;
300
+ if (state.pending || state.loadedFor === revision || (!retry && state.failedRevision === revision)) return;
301
+ const body = details.querySelector("[data-detail-body]");
302
+ const target = details.querySelector("[data-detail-content]");
303
+ const message = details.querySelector("[data-detail-message]");
304
+ if (!body || !target || !message) return;
305
+ const controller = new AbortController();
306
+ const request = { controller, revision };
307
+ state.pending = request;
308
+ state.failedRevision = undefined;
309
+ body.setAttribute("aria-busy", "true");
310
+ message.textContent = state.loadedRevision ? "" : labels.loading;
311
+ const timeout = setTimeout(() => controller.abort(), 15000);
312
+ try {
313
+ const url = new URL(eventsUrl, window.location.href);
314
+ url.searchParams.delete("events");
315
+ url.searchParams.set("detail", details.dataset.detailKey);
316
+ if (state.cursor !== undefined) url.searchParams.set("detailAfter", String(state.cursor));
317
+ const response = await fetch(url, { signal: controller.signal, cache: "no-store" });
318
+ if (!response.ok) throw new Error("Detail HTTP " + response.status);
319
+ const result = await response.json();
320
+ if (result.key !== details.dataset.detailKey || typeof result.content !== "string" || typeof result.revision !== "string") throw new Error("Invalid detail response");
321
+ if (state.pending !== request || !details.open || !details.isConnected) return;
322
+ if (details.dataset.detailRevision !== revision && result.revision !== details.dataset.detailRevision) return;
323
+ const positions = Array.from(target.querySelectorAll("[data-scroll-id]"), (item) => [item, item.scrollLeft, item.scrollTop]);
324
+ if (typeof result.outputAppend === "string") {
325
+ const output = target.querySelector(".tool-output:not(.error-output)");
326
+ if (!output) throw new Error("Output delta without a loaded body");
327
+ output.append(document.createTextNode(result.outputAppend));
328
+ const footer = target.querySelector(".tool-footer");
329
+ if (footer && result.footer) patchMarkup(footer, result.footer);
330
+ } else patchMarkup(target, result.content);
331
+ state.cursor = result.cursor;
332
+ window.agentBotDiagrams?.scan();
333
+ for (const [item, left, top] of positions) { if (item.isConnected) { item.scrollLeft = left; item.scrollTop = top; } }
334
+ state.loadedRevision = result.revision;
335
+ state.loadedFor = details.dataset.detailRevision;
336
+ message.textContent = "";
337
+ updateElapsed();
338
+ } catch {
339
+ if (state.pending !== request || !details.open || !details.isConnected) return;
340
+ state.cursor = undefined;
341
+ state.failedRevision = revision;
342
+ message.textContent = labels.loadFailed;
343
+ const button = document.createElement("button");
344
+ button.type = "button";
345
+ button.textContent = labels.retry;
346
+ button.addEventListener("click", () => { void loadDetails(details, true); });
347
+ message.append(button);
348
+ } finally {
349
+ clearTimeout(timeout);
350
+ if (state.pending === request) {
351
+ state.pending = undefined;
352
+ body.removeAttribute("aria-busy");
353
+ if (details.dataset.detailRevision !== revision) void loadDetails(details);
354
+ }
355
+ }
356
+ };
357
+ content.addEventListener("toggle", (event) => {
358
+ const details = event.target;
359
+ if (!details.matches?.("details[data-detail-key]")) return;
360
+ if (details.open) { void loadDetails(details, true); return; }
361
+ const state = detailState(details);
362
+ state.pending?.controller.abort();
363
+ state.pending = undefined;
364
+ details.querySelector("[data-detail-body]")?.removeAttribute("aria-busy");
365
+ const message = details.querySelector("[data-detail-message]");
366
+ if (message) message.textContent = "";
367
+ }, true);
368
+ const refreshDetails = () => {
369
+ for (const details of content.querySelectorAll("details[data-detail-key][open]")) void loadDetails(details);
370
+ };
371
+ window.addEventListener("pagehide", () => {
372
+ source?.close();
373
+ clearInterval(clock);
374
+ for (const details of content.querySelectorAll("details[data-detail-key]")) detailStates.get(details)?.pending?.controller.abort();
375
+ }, { once: true });
376
+ let previousContent;
377
+ let previousMetadata;
378
+ const applySnapshot = (update) => {
379
+ const contentChanged = update.content !== previousContent;
380
+ const scrollPositions = new Map(Array.from(contentChanged ? content.querySelectorAll("[data-scroll-id]") : [], (item) => [item.dataset.scrollId, [item.scrollLeft, item.scrollTop]]));
187
381
  const top = window.scrollY;
188
382
  const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
189
383
  const atBottom = maxTop - top <= 32;
190
- content.innerHTML = update.content;
191
- metadata.innerHTML = update.metadata;
384
+ if (contentChanged) {
385
+ patchMarkup(content, update.content);
386
+ window.agentBotDiagrams.scan();
387
+ previousContent = update.content;
388
+ }
389
+ if (update.metadata !== previousMetadata) {
390
+ patchMarkup(metadata, update.metadata);
391
+ previousMetadata = update.metadata;
392
+ }
192
393
  status.textContent = update.statusLabel;
193
394
  status.className = "status " + update.status;
194
- for (const item of content.querySelectorAll("details[data-activity-id]")) {
195
- if (disclosures.has(item.dataset.activityId)) item.open = disclosures.get(item.dataset.activityId);
196
- }
197
395
  for (const item of content.querySelectorAll("[data-scroll-id]")) {
198
396
  const position = scrollPositions.get(item.dataset.scrollId);
199
397
  if (position) { item.scrollLeft = position[0]; item.scrollTop = position[1]; }
200
398
  }
201
399
  updateElapsed();
400
+ refreshDetails();
401
+ if (!contentChanged) return;
202
402
  requestAnimationFrame(() => {
203
403
  const nextMaxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
204
404
  window.scrollTo(0, atBottom ? nextMaxTop : Math.min(top, nextMaxTop));
205
405
  });
206
406
  };
207
407
 
408
+ const applyPatch = (update) => {
409
+ const template = document.createElement("template");
410
+ template.innerHTML = update.content;
411
+ const incoming = template.content;
412
+ const timeline = content.querySelector('[data-preview-key="timeline"]');
413
+ const nextTimeline = incoming.querySelector('[data-preview-key="timeline"]');
414
+ for (const id of update.removed || []) {
415
+ for (const node of timeline.children) if (node.dataset.activity === id || node.dataset.activityId === id) node.remove();
416
+ }
417
+ for (const node of Array.from(nextTimeline.children)) {
418
+ const id = node.dataset.activity || node.dataset.activityId;
419
+ if (!id) continue;
420
+ timeline.querySelector(".empty")?.remove();
421
+ const current = Array.from(timeline.children).find((item) => (item.dataset.activity || item.dataset.activityId) === id);
422
+ if (current) patchNode(current, node);
423
+ else {
424
+ const nextId = update.before?.[id];
425
+ const anchor = Array.from(timeline.children).find((child) => (child.dataset.activity || child.dataset.activityId) === nextId);
426
+ timeline.insertBefore(node, anchor || null);
427
+ }
428
+ }
429
+ timeline.hidden = !timeline.children.length;
430
+ const incomingKeys = new Set(Array.from(incoming.children, (node) => node.dataset.previewKey));
431
+ for (const node of Array.from(incoming.children)) {
432
+ const key = nodeKey(node);
433
+ if (node.dataset.previewKey === "timeline" || !key) continue;
434
+ const current = Array.from(content.children).find((item) => nodeKey(item) === key);
435
+ if (current) patchNode(current, node);
436
+ else {
437
+ const result = content.querySelector('[data-preview-key="result"]');
438
+ content.insertBefore(node, node.dataset.activityId === "turn:files-summary" ? result : null);
439
+ }
440
+ }
441
+ for (const key of ["approval", "error", "plan", "result"]) {
442
+ if (!incomingKeys.has(key)) content.querySelector('[data-preview-key="' + key + '"]')?.remove();
443
+ }
444
+ patchMarkup(metadata, update.metadata);
445
+ status.textContent = update.statusLabel;
446
+ status.className = "status " + update.status;
447
+ window.agentBotDiagrams.scan();
448
+ refreshDetails();
449
+ updateElapsed();
450
+ };
451
+ if (terminalAtLoad || typeof EventSource !== "function") return;
208
452
  source = new EventSource(eventsUrl);
453
+ source.addEventListener("patch", (event) => {
454
+ try {
455
+ const update = JSON.parse(event.data);
456
+ applyPatch(update);
457
+ live.textContent = update.terminal ? "" : labels.live;
458
+ live.className = update.terminal ? "live terminal" : "live connected";
459
+ if (update.terminal) { source.close(); clearInterval(clock); }
460
+ } catch { live.textContent = labels.waitingForUpdates; }
461
+ });
209
462
  source.addEventListener("update", (event) => {
210
463
  try {
211
464
  const update = JSON.parse(event.data);
212
465
  if (typeof update.content !== "string" || typeof update.metadata !== "string") return;
213
- replaceSnapshot(update);
466
+ applySnapshot(update);
214
467
  live.textContent = update.terminal ? "" : labels.live;
215
468
  live.className = update.terminal ? "live terminal" : "live connected";
216
- if (update.terminal) source.close();
469
+ if (update.terminal) { source.close(); clearInterval(clock); }
217
470
  } catch {
218
471
  live.textContent = labels.waitingForUpdates;
219
472
  live.className = "live";
@@ -227,7 +480,6 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
227
480
  live.textContent = labels.reconnecting;
228
481
  live.className = "live disconnected";
229
482
  };
230
- window.addEventListener("pagehide", () => source.close(), { once: true });
231
483
  })();
232
484
  `;
233
485
 
@@ -256,11 +508,12 @@ export function renderTurnPreviewPage(input: {
256
508
  state: TurnViewState;
257
509
  eventsUrl: string;
258
510
  scriptPath: string;
511
+ diagramScriptPath?: string;
259
512
  localFileUrl?: (filePath: string) => string | undefined;
260
513
  language?: TurnPreviewLanguage;
261
514
  }): string {
262
515
  const language = input.language ?? "zh";
263
- const snapshot = renderTurnPreviewSnapshot(input.state, input.localFileUrl, language);
516
+ const snapshot = renderTurnPreviewSnapshot(input.state, input.localFileUrl, language, { deferDetails: true });
264
517
  const title = previewTitle(input.state);
265
518
  return `<!doctype html>
266
519
  <html lang="${language === "zh" ? "zh-CN" : "en"}">
@@ -268,9 +521,9 @@ export function renderTurnPreviewPage(input: {
268
521
  <meta charset="utf-8">
269
522
  <meta name="viewport" content="width=device-width, initial-scale=1">
270
523
  <title>${escapeHtml(title)} · Agent Bot</title>
271
- <style>${TURN_PREVIEW_CSS}</style>
524
+ <style>${TURN_PREVIEW_CSS}${DIAGRAM_PREVIEW_CSS}${PREVIEW_SYNTAX_CSS}</style>
272
525
  </head>
273
- <body data-events-url="${escapeAttribute(input.eventsUrl)}">
526
+ <body data-terminal="${snapshot.terminal}" data-events-url="${escapeAttribute(input.eventsUrl)}" data-diagram-script="${escapeAttribute(input.diagramScriptPath ?? "")}">
274
527
  <header class="page-header">
275
528
  <div class="header-inner">
276
529
  <div class="header-top">
@@ -287,10 +540,27 @@ export function renderTurnPreviewPage(input: {
287
540
  </html>`;
288
541
  }
289
542
 
543
+ export function renderTurnPreviewPatch(
544
+ state: TurnViewState, changed: Set<string>, removed: Set<string>,
545
+ localFileUrl: (filePath: string) => string | undefined, language: TurnPreviewLanguage,
546
+ ): TurnPreviewSnapshot & { removed: string[]; before: Record<string, string> } {
547
+ const partial = { ...state,
548
+ activities: state.activities.filter((a) => changed.has(a.id)),
549
+ reasoningItems: state.reasoningItems?.filter((r) => changed.has(`reasoning:${r.itemId}`)),
550
+ };
551
+ const order = [
552
+ ...(state.reasoningItems ?? []).filter((r) => !r.afterActivityId).map((r) => `reasoning:${r.itemId}`),
553
+ ...state.activities.flatMap((a) => [a.id, ...(state.reasoningItems ?? []).filter((r) => r.afterActivityId === a.id).map((r) => `reasoning:${r.itemId}`)]),
554
+ ];
555
+ const before = Object.fromEntries(order.flatMap((id, i) => changed.has(id) && order[i + 1] ? [[id, order[i + 1]]] : []));
556
+ return { ...renderTurnPreviewSnapshot(partial, localFileUrl, language, { deferDetails: true }), removed: [...removed], before };
557
+ }
558
+
290
559
  export function renderTurnPreviewSnapshot(
291
560
  state: TurnViewState,
292
561
  localFileUrl?: (filePath: string) => string | undefined,
293
562
  language: TurnPreviewLanguage = "zh",
563
+ options: { deferDetails?: boolean } = {},
294
564
  ): TurnPreviewSnapshot {
295
565
  const labels = previewLabels(language);
296
566
  const resolveFileUrl = localFileUrl ? (filePath: string): string | undefined => {
@@ -298,25 +568,35 @@ export function renderTurnPreviewSnapshot(
298
568
  if (!state.projectCwd || !path.isAbsolute(state.projectCwd)) return undefined;
299
569
  return localFileUrl(path.resolve(state.projectCwd, filePath));
300
570
  } : undefined;
301
- const renderText = (value: string): string => renderMarkdown(value, state.projectCwd, localFileUrl);
302
- const timeline = renderTimeline(state.activities ?? [], renderText, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd);
571
+ const renderText = (value: string): string => renderMarkdown(value, state.projectCwd, localFileUrl, language);
572
+ const timeline = renderTimeline(state, renderText, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd, options.deferDetails);
573
+ const terminal = isTerminal(state.status);
574
+ const waitingForProgress = !terminal && !state.finalResponse && !state.assistantText
575
+ && !state.error && !state.approval && state.status !== "waiting_for_approval"
576
+ && state.plan.length === 0 && state.fileSummary.length === 0;
577
+ const timelineContent = timeline || (waitingForProgress
578
+ ? `<div class="empty">${escapeHtml(language === "zh" ? "正在等待 Agent 返回进度…" : "Waiting for Agent progress…")}</div>`
579
+ : "");
303
580
  const sections = [
304
- state.prompt ? renderMessageActivity("prompt", state.prompt, "user prompt", renderText) : "",
581
+ state.prompt || state.promptImagePaths?.length ? renderMessageActivity("prompt", state.prompt ?? "", "user prompt", renderText, renderMessageImages(state.promptImagePaths, resolveFileUrl, language)) : "",
582
+ state.historyDetailError
583
+ ? `<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>`
584
+ : state.historyDetail === "summary"
585
+ ? `<section class="notice" data-preview-key="history-detail">${language === "zh" ? "当前仅保存轮次摘要,尚未加载历史执行详情。" : "Only a Turn summary is saved; execution details have not been loaded."}</section>` : "",
305
586
  state.plan.length > 0 ? renderPlan(state, language) : "",
306
587
  state.activitiesTruncated
307
- ? `<div class="notice">${escapeHtml(labels.truncated)}</div>`
588
+ ? `<div class="notice" data-preview-key="truncated">${escapeHtml(labels.truncated)}</div>`
308
589
  : "",
309
- timeline ? `<section class="timeline">${timeline}</section>` : `<div class="empty">${escapeHtml(language === "zh" ? "正在等待 Agent 返回进度…" : "Waiting for Agent progress…")}</div>`,
310
- state.fileSummary.length > 0 ? renderFileSummary(state, language, resolveFileUrl) : "",
311
- 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>` : "",
312
- state.error ? `<section class="result error-result"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(state.error)}</pre></section>` : "",
590
+ `<section class="timeline" data-preview-key="timeline"${timelineContent ? "" : " hidden"}>${timelineContent}</section>`,
591
+ state.fileSummary.length > 0 ? renderFileSummary(state, language, resolveFileUrl, options.deferDetails) : "",
592
+ 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>` : "",
593
+ state.error ? `<section class="result error-result" data-preview-key="error"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(state.error)}</pre></section>` : "",
313
594
  state.finalResponse
314
- ? `<section class="result final-result"><h2>${escapeHtml(labels.finalAnswer)}</h2><div class="markdown">${renderText(state.finalResponse)}</div></section>`
595
+ ? `<section class="result final-result" data-preview-key="result"><h2>${escapeHtml(labels.finalAnswer)}</h2><div class="markdown">${renderText(state.finalResponse)}</div></section>`
315
596
  : state.assistantText
316
- ? `<section class="result"><h2>${escapeHtml(labels.generating)}</h2><div class="markdown">${renderText(state.assistantText)}</div></section>`
597
+ ? `<section class="result" data-preview-key="result"><h2>${escapeHtml(labels.generating)}</h2><div class="markdown">${renderText(state.assistantText)}</div></section>`
317
598
  : "",
318
599
  ].filter(Boolean).join("");
319
- const terminal = isTerminal(state.status);
320
600
  return {
321
601
  content: sections,
322
602
  metadata: renderMetadata(state, language),
@@ -333,22 +613,104 @@ function renderPlan(state: TurnViewState, language: TurnPreviewLanguage): string
333
613
  const marker = step.status === "completed" ? "✓" : step.status === "in_progress" ? "↻" : "○";
334
614
  return `<li class="${step.status}"><span>${marker}</span><span>${escapeHtml(step.text)}</span></li>`;
335
615
  }).join("");
336
- return `<section class="plan"><h2>${escapeHtml(labels.plan)} <span>${completed}/${state.plan.length}</span></h2><ol>${items}</ol></section>`;
616
+ return `<section class="plan" data-preview-key="plan"><h2>${escapeHtml(labels.plan)} <span>${completed}/${state.plan.length}</span></h2><ol>${items}</ol></section>`;
337
617
  }
338
618
 
339
619
  function renderTimeline(
340
- activities: TurnActivity[],
620
+ state: TurnViewState,
341
621
  renderText: (value: string) => string,
342
622
  localFileUrl?: (filePath: string) => string | undefined,
343
623
  fullToolOutputs?: Record<string, string>,
344
624
  fullToolErrors?: Record<string, string>,
345
625
  language: TurnPreviewLanguage = "zh",
346
626
  projectCwd?: string,
627
+ deferDetails = false,
347
628
  ): string {
348
- return activities
349
- .filter((activity): activity is Exclude<TurnActivity, { kind: "reasoning" }> => activity.kind !== "reasoning")
350
- .map((activity) => renderActivity(activity, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd))
351
- .join("");
629
+ const activities = state.activities ?? [];
630
+ const positions = new Map(activities.map((activity, index) => [activity.id, index]));
631
+ const reasoning = new Map<number, TurnReasoningItem[]>();
632
+ for (const item of turnReasoningItems(state)) {
633
+ const position = item.afterActivityId ? positions.get(item.afterActivityId) ?? -1 : -1;
634
+ const items = reasoning.get(position) ?? [];
635
+ items.push(item);
636
+ reasoning.set(position, items);
637
+ }
638
+ const renderReasoning = (position: number): string => (reasoning.get(position) ?? [])
639
+ .map((item) => renderReasoningItem(item, state.projectCwd, localFileUrl, language, deferDetails)).join("");
640
+ return renderReasoning(-1) + activities.map((activity, index) => {
641
+ const visible = activity.kind === "reasoning"
642
+ ? activity.id.startsWith("commentary:") ? { ...activity, kind: "assistant" as const } : undefined
643
+ : activity;
644
+ return (visible ? renderActivity(visible, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd, deferDetails) : "")
645
+ + renderReasoning(index);
646
+ }).join("");
647
+ }
648
+
649
+ function reasoningPresentation(item: TurnReasoningItem): {
650
+ title?: string;
651
+ sections: Array<{ field: "summary" | "content"; parts: Array<{ index: number; text: string }> }>;
652
+ } {
653
+ let title: string | undefined;
654
+ const sections = (["summary", "content"] as const).map((field) => ({
655
+ field,
656
+ parts: item[field].map((text, index) => {
657
+ const heading = !title && text?.trim() ? leadingReasoningHeading(text) : undefined;
658
+ if (heading) title = heading.title;
659
+ return { index, text: heading?.body ?? text };
660
+ }).filter((part) => part.text?.trim()),
661
+ }));
662
+ return { title, sections };
663
+ }
664
+
665
+ function leadingReasoningHeading(text: string): { title: string; body: string } | undefined {
666
+ const first = /^(?:[ \t]*\r?\n)*([^\r\n]*)(?:\r?\n|$)/u.exec(text);
667
+ if (!first || !/^ {0,3}(?:#{1,6}[ \t]+|\*\*|__)/u.test(first[1])) return undefined;
668
+ // Parse only a possible title line; collapsed panels must not render the full Markdown.
669
+ const [block, inline] = MARKDOWN.parse(first[1], {});
670
+ const tokens = inline?.children?.filter((token) => token.type !== "text" || token.content !== "") ?? [];
671
+ const strongTitle = block?.type === "paragraph_open"
672
+ && tokens[0]?.type === "strong_open" && tokens.at(-1)?.type === "strong_close"
673
+ && tokens.slice(1, -1).every((token) => token.level > 0);
674
+ if (block?.type !== "heading_open" && !strongTitle) return undefined;
675
+ const title = tokens.map((token) => token.content).join("").trim();
676
+ if (!title) return undefined;
677
+ // Keep linked/image headings in the body so moving their text never discards an attachment or link.
678
+ const keepHeading = tokens.some((token) => token.type === "link_open" || token.type === "image");
679
+ return { title, body: keepHeading ? text : text.slice(first[0].length) };
680
+ }
681
+
682
+ function renderReasoningItem(
683
+ item: TurnReasoningItem,
684
+ cwd: string | undefined,
685
+ localFileUrl: ((filePath: string) => string | undefined) | undefined,
686
+ language: TurnPreviewLanguage,
687
+ deferDetails: boolean,
688
+ ): string {
689
+ const presentation = reasoningPresentation(item);
690
+ if (!presentation.title && !presentation.sections.some((section) => section.parts.length > 0)) return "";
691
+ const title = presentation.title ?? previewLabels(language).reasoning;
692
+ const lazy = deferDetails ? lazyDetailAttributes(`reasoning:${item.itemId}`, reasoningRevision(item, cwd)) : "";
693
+ const body = deferDetails ? lazyDetailBody() : renderReasoningBody(item, cwd, localFileUrl, language, presentation);
694
+ 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>`;
695
+ }
696
+
697
+ function renderReasoningBody(
698
+ item: TurnReasoningItem,
699
+ cwd: string | undefined,
700
+ localFileUrl: ((filePath: string) => string | undefined) | undefined,
701
+ language: TurnPreviewLanguage,
702
+ presentation = reasoningPresentation(item),
703
+ ): string {
704
+ return presentation.sections.map(({ field, parts }) => {
705
+ if (parts.length === 0) return "";
706
+ const sections = parts.map(({ index, text }) => `<div class="markdown reasoning-part" data-reasoning-part="${index}">${renderMarkdown(text, cwd, localFileUrl, language)}</div>`).join("");
707
+ return `<section class="reasoning-section" data-reasoning-field="${field}">${sections}</section>`;
708
+ }).join("");
709
+ }
710
+
711
+ function reasoningRevision(item: TurnReasoningItem, cwd?: string): string {
712
+ if (item.previewRevision !== undefined) return String(item.previewRevision);
713
+ return createHash("sha256").update(JSON.stringify([item.summary, item.content, cwd])).digest("hex").slice(0, 24);
352
714
  }
353
715
 
354
716
  function renderActivity(
@@ -359,6 +721,7 @@ function renderActivity(
359
721
  fullToolErrors?: Record<string, string>,
360
722
  language: TurnPreviewLanguage = "zh",
361
723
  projectCwd?: string,
724
+ deferDetails = false,
362
725
  ): string {
363
726
  switch (activity.kind) {
364
727
  case "assistant":
@@ -367,14 +730,32 @@ function renderActivity(
367
730
  }
368
731
  return renderMessageActivity(activity.id, activity.text, "commentary", renderText);
369
732
  case "user":
370
- return renderMessageActivity(activity.id, activity.text, "user", renderText);
733
+ return renderMessageActivity(activity.id, activity.text, "user", renderText, renderMessageImages(activity.localImagePaths, localFileUrl, language));
371
734
  case "tool":
372
- return renderToolActivity(activity.id, activity.tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd);
735
+ return renderToolActivity(activity.id, activity.tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd, deferDetails);
373
736
  }
374
737
  }
375
738
 
376
- function renderMessageActivity(id: string, text: string, kind: string, renderText: (value: string) => string): string {
377
- return `<article class="activity message ${kind}" data-activity="${escapeAttribute(id)}"><div class="markdown">${renderText(text)}</div></article>`;
739
+ function renderMessageActivity(id: string, text: string, kind: string, renderText: (value: string) => string, images = ""): string {
740
+ return `<article class="activity message ${kind}" data-activity="${escapeAttribute(id)}"><div class="markdown">${renderText(text)}${images}</div></article>`;
741
+ }
742
+
743
+ function renderMessageImages(paths: string[] | undefined, localFileUrl: ((filePath: string) => string | undefined) | undefined, language: TurnPreviewLanguage): string {
744
+ if (!paths?.length) return "";
745
+ return [...new Set(paths)].map((filePath, index) => {
746
+ const label = language === "zh" ? `图片 ${index + 1}` : `Image ${index + 1}`;
747
+ const unavailable = `<p class="muted">${escapeHtml(label)}${language === "zh" ? "不可用" : " unavailable"}</p>`;
748
+ const url = localFileUrl?.(filePath);
749
+ if (!url) return unavailable;
750
+ try {
751
+ const raw = new URL(url);
752
+ if (raw.protocol !== "http:" && raw.protocol !== "https:") return unavailable;
753
+ raw.searchParams.set("raw", "1");
754
+ return `<p class="message-image"><img src="${escapeAttribute(raw.toString())}" alt="${escapeAttribute(label)}" loading="lazy"></p>`;
755
+ } catch {
756
+ return unavailable;
757
+ }
758
+ }).join("");
378
759
  }
379
760
 
380
761
  function renderToolActivity(
@@ -385,6 +766,7 @@ function renderToolActivity(
385
766
  fullToolErrors?: Record<string, string>,
386
767
  language: TurnPreviewLanguage = "zh",
387
768
  projectCwd?: string,
769
+ deferDetails = false,
388
770
  ): string {
389
771
  const labels = previewLabels(language);
390
772
  const command = tool.command ? (displayToolCommand(tool.command) || tool.command.trim()) : undefined;
@@ -395,6 +777,34 @@ function renderToolActivity(
395
777
  ? formatToolCommand(command)
396
778
  : undefined;
397
779
  const title = displayCommand ? summarizeToolCommand(displayCommand) : toolTitle(tool, command, repl, language);
780
+ const titleMarkup = displayCommand
781
+ ? `<code class="tool-command-title">${escapeHtml(title)}</code>`
782
+ : `<span class="tool-title">${escapeHtml(title)}</span>`;
783
+ const timing = renderToolTiming(tool, "header");
784
+ const statusLabel = toolStatusLabel(tool.status, language);
785
+ const icon = `<span class="tool-status-icon" role="img" aria-label="${escapeAttribute(statusLabel)}" title="${escapeAttribute(statusLabel)}">${toolStatusIcon(tool.status)}</span>`;
786
+ const header = `<summary class="tool-header">${icon}<span class="tool-summary-content">${titleMarkup}</span><span class="tool-meta">${timing}</span></summary>`;
787
+ const body = deferDetails
788
+ ? lazyDetailBody()
789
+ : renderToolBody(activityId, tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd);
790
+ const lazy = deferDetails ? lazyDetailAttributes(`tool:${activityId}`, toolDetailRevision(tool, fullToolOutputs, fullToolErrors, projectCwd)) : "";
791
+ 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>`;
792
+ }
793
+
794
+ function renderToolBody(
795
+ activityId: string,
796
+ tool: ToolState,
797
+ localFileUrl?: (filePath: string) => string | undefined,
798
+ fullToolOutputs?: Record<string, string>,
799
+ fullToolErrors?: Record<string, string>,
800
+ language: TurnPreviewLanguage = "zh",
801
+ projectCwd?: string,
802
+ ): string {
803
+ const labels = previewLabels(language);
804
+ const command = tool.command ? (displayToolCommand(tool.command) || tool.command.trim()) : undefined;
805
+ const repl = isReplTool(tool);
806
+ const displayCommand = repl ? formatReplCommand(command ?? "") : command ? formatToolCommand(command) : undefined;
807
+ const status = `<span class="tool-state ${tool.status}">${escapeHtml(toolStatusLabel(tool.status, language))}</span>`;
398
808
  const output = fullToolOutputs?.[tool.id] ?? tool.output;
399
809
  const error = fullToolErrors?.[tool.id] ?? tool.error;
400
810
  const sameError = Boolean(output && error && normalizeOutput(output) === normalizeOutput(error));
@@ -413,21 +823,18 @@ function renderToolActivity(
413
823
  ? `<ul class="files" aria-label="${escapeAttribute(labels.files)}">${tool.files.map((file) => renderFileEntry(file, localFileUrl, projectCwd)).join("")}</ul>`
414
824
  : "",
415
825
  ].filter(Boolean).join("");
416
- const titleMarkup = displayCommand
417
- ? `<code class="tool-command-title">${escapeHtml(title)}</code>`
418
- : `<span class="tool-title">${escapeHtml(title)}</span>`;
419
- const timing = renderToolTiming(tool, "header");
420
- const statusLabel = toolStatusLabel(tool.status, language);
421
- const icon = `<span class="tool-status-icon" role="img" aria-label="${escapeAttribute(statusLabel)}" title="${escapeAttribute(statusLabel)}">${toolStatusIcon(tool.status)}</span>`;
422
- const status = `<span class="tool-state ${tool.status}">${escapeHtml(statusLabel)}</span>`;
423
- const header = `<summary class="tool-header">${icon}<span class="tool-summary-content">${titleMarkup}</span><span class="tool-meta">${timing}</span></summary>`;
826
+ const footer = renderToolFooter(tool, (displayOutput?.length ?? 0) + (displayError?.length ?? 0), language);
827
+ 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>`;
828
+ }
829
+
830
+ export function renderToolFooter(tool: ToolState, characterCount: number, language: TurnPreviewLanguage): string {
831
+ const labels = previewLabels(language);
832
+ const status = `<span class="tool-state ${tool.status}">${escapeHtml(toolStatusLabel(tool.status, language))}</span>`;
424
833
  const startedAt = tool.startedAt === undefined ? undefined : new Date(tool.startedAt);
425
834
  const startTime = startedAt
426
835
  ? `<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>`
427
836
  : `<span title="${escapeAttribute(labels.startingTime)}">${escapeHtml(labels.unknown)}</span>`;
428
- const characterCount = (displayOutput?.length ?? 0) + (displayError?.length ?? 0);
429
- const footer = `<div class="tool-footer">${status}${startTime}${renderToolTiming(tool, "footer")}<span>${formatNumber(characterCount)} ${escapeHtml(labels.characters)}</span></div>`;
430
- 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>`;
837
+ return `${status}${startTime}${renderToolTiming(tool, "footer")}<span>${formatNumber(characterCount)} ${escapeHtml(labels.characters)}</span>`;
431
838
  }
432
839
 
433
840
  function toolTitle(tool: ToolState, command?: string, repl = false, language: TurnPreviewLanguage = "zh"): string {
@@ -599,10 +1006,69 @@ function renderFileSummary(
599
1006
  state: TurnViewState,
600
1007
  language: TurnPreviewLanguage,
601
1008
  localFileUrl?: (filePath: string) => string | undefined,
1009
+ deferDetails = false,
602
1010
  ): string {
603
1011
  const labels = previewLabels(language);
604
- const files = state.fileSummary.map((file) => renderFileEntry(file, localFileUrl, state.projectCwd)).join("");
605
- 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>`;
1012
+ const body = deferDetails ? lazyDetailBody() : renderFileSummaryBody(state, localFileUrl);
1013
+ const lazy = deferDetails ? lazyDetailAttributes("files", fileSummaryRevision(state)) : "";
1014
+ 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>`;
1015
+ }
1016
+
1017
+ function renderFileSummaryBody(state: TurnViewState, localFileUrl?: (filePath: string) => string | undefined): string {
1018
+ return `<ul class="files">${state.fileSummary.map((file) => renderFileEntry(file, localFileUrl, state.projectCwd)).join("")}</ul>`;
1019
+ }
1020
+
1021
+ function lazyDetailAttributes(key: string, revision: string): string {
1022
+ return ` data-detail-key="${escapeAttribute(key)}" data-detail-revision="${revision}"`;
1023
+ }
1024
+
1025
+ function lazyDetailBody(): string {
1026
+ return '<div data-detail-body><div class="detail-message" data-detail-message role="status"></div><div data-detail-content></div></div>';
1027
+ }
1028
+
1029
+ function toolDetailRevision(tool: ToolState, outputs?: Record<string, string>, errors?: Record<string, string>, cwd?: string): string {
1030
+ if (tool.previewRevision !== undefined) return String(tool.previewRevision);
1031
+ return createHash("sha256").update(JSON.stringify([tool, outputs?.[tool.id], errors?.[tool.id], cwd])).digest("hex").slice(0, 24);
1032
+ }
1033
+
1034
+ function fileSummaryRevision(state: TurnViewState): string {
1035
+ return createHash("sha256").update(JSON.stringify([state.fileSummary, state.projectCwd])).digest("hex").slice(0, 24);
1036
+ }
1037
+
1038
+ export interface TurnPreviewDetail {
1039
+ key: string;
1040
+ revision: string;
1041
+ content: string;
1042
+ cursor?: number;
1043
+ }
1044
+
1045
+ export function renderTurnPreviewDetail(
1046
+ state: TurnViewState,
1047
+ key: string,
1048
+ localFileUrl?: (filePath: string) => string | undefined,
1049
+ language: TurnPreviewLanguage = "zh",
1050
+ ): TurnPreviewDetail | undefined {
1051
+ const resolveFileUrl = localFileUrl ? (filePath: string): string | undefined => {
1052
+ if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath)) return localFileUrl(filePath);
1053
+ if (!state.projectCwd || !path.isAbsolute(state.projectCwd)) return undefined;
1054
+ return localFileUrl(path.resolve(state.projectCwd, filePath));
1055
+ } : undefined;
1056
+ if (key === "files" && state.fileSummary.length > 0) {
1057
+ return { key, revision: fileSummaryRevision(state), content: renderFileSummaryBody(state, resolveFileUrl) };
1058
+ }
1059
+ if (key.startsWith("reasoning:")) {
1060
+ const item = turnReasoningItems(state).find((item) => `reasoning:${item.itemId}` === key);
1061
+ if (!item || ![...item.summary, ...item.content].some((text) => text?.trim())) return undefined;
1062
+ return { key, revision: reasoningRevision(item, state.projectCwd),
1063
+ content: renderReasoningBody(item, state.projectCwd, localFileUrl, language) };
1064
+ }
1065
+ const activity = state.activities?.find((item) => item.kind === "tool" && `tool:${item.id}` === key);
1066
+ if (activity?.kind !== "tool") return undefined;
1067
+ return {
1068
+ key,
1069
+ revision: toolDetailRevision(activity.tool, state.fullToolOutputs, state.fullToolErrors, state.projectCwd),
1070
+ content: renderToolBody(activity.id, activity.tool, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd),
1071
+ };
606
1072
  }
607
1073
 
608
1074
  function renderFileEntry(file: FileSummary, localFileUrl?: (filePath: string) => string | undefined, projectCwd?: string): string {
@@ -629,11 +1095,11 @@ function renderMetadata(state: TurnViewState, language: TurnPreviewLanguage): st
629
1095
  return [
630
1096
  elapsed,
631
1097
  state.model?.trim() ? `<span title="${escapeAttribute(labels.model)}">${escapeHtml(state.model.trim())}</span>` : "",
1098
+ state.modelProvider?.trim() ? `<span title="Provider">Provider: ${escapeHtml(state.modelProvider.trim())}</span>` : "",
632
1099
  state.totalTokens === undefined ? "" : state.cachedInputTokens === undefined
633
1100
  ? `<span title="${escapeAttribute(labels.turnTokens)}">${formatTokenCount(state.totalTokens)} tokens</span>`
634
1101
  : `<span title="${escapeAttribute(labels.nonCachedTokens)}: ${formatNumber(state.totalTokens)} tokens">${escapeHtml(labels.nonCachedTokens)} ${formatTokenCount(state.totalTokens)} tokens</span>`,
635
1102
  state.totalTokensIncludingCache === undefined ? "" : `<span title="${escapeAttribute(labels.totalTokens)}: ${formatNumber(state.totalTokensIncludingCache)} tokens">${escapeHtml(labels.totalTokens)} ${formatTokenCount(state.totalTokensIncludingCache)} tokens</span>`,
636
- state.cachedInputTokens === undefined ? "" : `<span title="${escapeAttribute(labels.cachedTokens)}: ${formatNumber(state.cachedInputTokens)} tokens">${escapeHtml(labels.cachedTokens)} ${formatTokenCount(state.cachedInputTokens)} tokens</span>`,
637
1103
  state.contextCompactionAfterTokens === undefined ? "" : state.contextCompactionBeforeTokens === undefined
638
1104
  ? `<span title="${escapeAttribute(labels.compactionAfterTokens)}">${formatTokenCount(state.contextCompactionAfterTokens)} tokens</span>`
639
1105
  : `<span title="${escapeAttribute(labels.compactionTokens)}">${formatTokenCount(state.contextCompactionBeforeTokens)} → ${formatTokenCount(state.contextCompactionAfterTokens)} tokens</span>`,
@@ -646,8 +1112,8 @@ function previewTitle(state: TurnViewState): string {
646
1112
  return value.length > 80 ? `${value.slice(0, 79)}…` : value;
647
1113
  }
648
1114
 
649
- function renderMarkdown(value: string, projectCwd?: string, localFileUrl?: (filePath: string) => string | undefined): string {
650
- const environment = {};
1115
+ function renderMarkdown(value: string, projectCwd?: string, localFileUrl?: (filePath: string) => string | undefined, language: TurnPreviewLanguage = "zh"): string {
1116
+ const environment = { language };
651
1117
  const tokens = MARKDOWN.parse(value, environment);
652
1118
  // Only the directory is used to resolve relative links; no Markdown file is created.
653
1119
  const markdownPath = projectCwd && path.isAbsolute(projectCwd) ? path.join(projectCwd, "turn.md") : undefined;
@@ -712,6 +1178,10 @@ function escapeAttribute(value: string): string {
712
1178
  const TURN_PREVIEW_CSS = `
713
1179
  :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; }
714
1180
  * { box-sizing: border-box; letter-spacing: 0; }
1181
+ html { scrollbar-gutter: stable; }
1182
+ @supports not (scrollbar-gutter: stable) {
1183
+ html { overflow-y: scroll; }
1184
+ }
715
1185
  body { margin: 0; background: var(--page); color: var(--text); font-size: 13px; line-height: 1.55; }
716
1186
  .page-header { position: sticky; top: 0; z-index: 3; background: var(--page); border-bottom: 1px solid var(--line); }
717
1187
  .header-inner { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 12px; max-width: 1320px; margin: auto; padding: 6px 20px; }
@@ -756,6 +1226,11 @@ main { max-width: 1320px; margin: 0 auto; padding: 0 20px 40px; }
756
1226
  .message > .markdown, .result > .markdown { font-size: 14px; font-weight: 400; }
757
1227
  .markdown > :first-child { margin-top: 0; }
758
1228
  .markdown > :last-child { margin-bottom: 0; }
1229
+ .reasoning-step { margin:8px 0; border:1px solid var(--line); border-radius:7px; min-width:0; }
1230
+ .reasoning-header { cursor:pointer; padding:8px 10px; color:var(--muted); }
1231
+ .reasoning-title { min-width:0; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1232
+ .reasoning-section { margin:0 10px 10px; max-height:30em; overflow:auto; overflow-wrap:anywhere; }
1233
+ .reasoning-part + .reasoning-part { border-top:1px solid var(--line); padding-top:8px; }
759
1234
  .markdown p, .markdown ul, .markdown ol, .markdown pre, .markdown blockquote { margin: 0 0 6px; }
760
1235
  .markdown ul, .markdown ol { padding-left: 1.5em; }
761
1236
  .markdown h1, .markdown h2, .markdown h3 { margin: 12px 0 6px; font-size: 14px; }
@@ -830,6 +1305,9 @@ summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px;
830
1305
  .additions { color: var(--success); }
831
1306
  .deletions { color: var(--danger); }
832
1307
  .file-delta { display: inline-flex; flex: none; gap: 6px; font: 12px "Cascadia Mono", Consolas, monospace; }
1308
+ .detail-message { padding: 8px 12px; color: var(--muted); }
1309
+ .detail-message:empty { display: none; }
1310
+ .detail-message button { margin-left: 8px; cursor: pointer; }
833
1311
  .tool-image img { display: block; max-width: 100%; max-height: 720px; }
834
1312
  .final-result h2 { color: var(--success); }
835
1313
  @media (max-width: 700px) {