wezard 0.3.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +14 -0
  3. package/CLAUDE.md +83 -0
  4. package/LICENSE +21 -0
  5. package/README.md +410 -0
  6. package/cli/weclaude.sh +236 -0
  7. package/commands/audit.md +7 -0
  8. package/commands/wrc.md +7 -0
  9. package/config.example.jsonc +102 -0
  10. package/dist/cli/audit.js +156 -0
  11. package/dist/cli/audit.js.map +1 -0
  12. package/dist/cli/init.js +311 -0
  13. package/dist/cli/init.js.map +1 -0
  14. package/dist/cli/sync.js +175 -0
  15. package/dist/cli/sync.js.map +1 -0
  16. package/dist/daemon/approval.js +1301 -0
  17. package/dist/daemon/approval.js.map +1 -0
  18. package/dist/daemon/ask.js +101 -0
  19. package/dist/daemon/ask.js.map +1 -0
  20. package/dist/daemon/audit.js +168 -0
  21. package/dist/daemon/audit.js.map +1 -0
  22. package/dist/daemon/cc-bridge.js +204 -0
  23. package/dist/daemon/cc-bridge.js.map +1 -0
  24. package/dist/daemon/cfg-sync.js +240 -0
  25. package/dist/daemon/cfg-sync.js.map +1 -0
  26. package/dist/daemon/claim.js +95 -0
  27. package/dist/daemon/claim.js.map +1 -0
  28. package/dist/daemon/detail.js +153 -0
  29. package/dist/daemon/detail.js.map +1 -0
  30. package/dist/daemon/graph.js +176 -0
  31. package/dist/daemon/graph.js.map +1 -0
  32. package/dist/daemon/http.js +83 -0
  33. package/dist/daemon/http.js.map +1 -0
  34. package/dist/daemon/inbound.js +919 -0
  35. package/dist/daemon/inbound.js.map +1 -0
  36. package/dist/daemon/index.js +569 -0
  37. package/dist/daemon/index.js.map +1 -0
  38. package/dist/daemon/last-response.js +59 -0
  39. package/dist/daemon/last-response.js.map +1 -0
  40. package/dist/daemon/mirror-bridge.js +3512 -0
  41. package/dist/daemon/mirror-bridge.js.map +1 -0
  42. package/dist/daemon/mirror-store.js +39 -0
  43. package/dist/daemon/mirror-store.js.map +1 -0
  44. package/dist/daemon/outbound.js +42 -0
  45. package/dist/daemon/outbound.js.map +1 -0
  46. package/dist/daemon/peers.js +146 -0
  47. package/dist/daemon/peers.js.map +1 -0
  48. package/dist/daemon/pending.js +90 -0
  49. package/dist/daemon/pending.js.map +1 -0
  50. package/dist/daemon/quota.js +164 -0
  51. package/dist/daemon/quota.js.map +1 -0
  52. package/dist/daemon/redact.js +27 -0
  53. package/dist/daemon/redact.js.map +1 -0
  54. package/dist/daemon/session-cache.js +149 -0
  55. package/dist/daemon/session-cache.js.map +1 -0
  56. package/dist/daemon/session-scan.js +273 -0
  57. package/dist/daemon/session-scan.js.map +1 -0
  58. package/dist/daemon/sessions.js +35 -0
  59. package/dist/daemon/sessions.js.map +1 -0
  60. package/dist/daemon/spawn-tmux.js +213 -0
  61. package/dist/daemon/spawn-tmux.js.map +1 -0
  62. package/dist/daemon/tips.js +25 -0
  63. package/dist/daemon/tips.js.map +1 -0
  64. package/dist/daemon/topics.js +197 -0
  65. package/dist/daemon/topics.js.map +1 -0
  66. package/dist/daemon/usage.js +358 -0
  67. package/dist/daemon/usage.js.map +1 -0
  68. package/dist/daemon/wedoc.js +210 -0
  69. package/dist/daemon/wedoc.js.map +1 -0
  70. package/dist/daemon/ws.js +67 -0
  71. package/dist/daemon/ws.js.map +1 -0
  72. package/dist/mcp/server.js +385 -0
  73. package/dist/mcp/server.js.map +1 -0
  74. package/dist/shared/ansi.js +134 -0
  75. package/dist/shared/ansi.js.map +1 -0
  76. package/dist/shared/chat-http.js +195 -0
  77. package/dist/shared/chat-http.js.map +1 -0
  78. package/dist/shared/chat-render.js +53 -0
  79. package/dist/shared/chat-render.js.map +1 -0
  80. package/dist/shared/chat-view.js +116 -0
  81. package/dist/shared/chat-view.js.map +1 -0
  82. package/dist/shared/cli-backends.js +304 -0
  83. package/dist/shared/cli-backends.js.map +1 -0
  84. package/dist/shared/config-writer.js +39 -0
  85. package/dist/shared/config-writer.js.map +1 -0
  86. package/dist/shared/config.js +278 -0
  87. package/dist/shared/config.js.map +1 -0
  88. package/dist/shared/detail-render.js +836 -0
  89. package/dist/shared/detail-render.js.map +1 -0
  90. package/dist/shared/detail-store.js +205 -0
  91. package/dist/shared/detail-store.js.map +1 -0
  92. package/dist/shared/highlight.js +154 -0
  93. package/dist/shared/highlight.js.map +1 -0
  94. package/dist/shared/lan-ip.js +35 -0
  95. package/dist/shared/lan-ip.js.map +1 -0
  96. package/dist/shared/log.js +18 -0
  97. package/dist/shared/log.js.map +1 -0
  98. package/dist/shared/md-chunk.js +99 -0
  99. package/dist/shared/md-chunk.js.map +1 -0
  100. package/dist/shared/paths.js +12 -0
  101. package/dist/shared/paths.js.map +1 -0
  102. package/dist/shared/session-label.js +67 -0
  103. package/dist/shared/session-label.js.map +1 -0
  104. package/dist/shared/web-assets.js +51 -0
  105. package/dist/shared/web-assets.js.map +1 -0
  106. package/dist/svr/index.js +246 -0
  107. package/dist/svr/index.js.map +1 -0
  108. package/hooks/hooks.json +16 -0
  109. package/hooks/pre-tool-use.sh +167 -0
  110. package/launchd/com.weclaude.daemon.plist.template +45 -0
  111. package/package.json +97 -0
  112. package/scripts/install.sh +73 -0
  113. package/scripts/postinstall.sh +12 -0
  114. package/scripts/uninstall.sh +44 -0
  115. package/systemd/weclaude.service.template +17 -0
  116. package/web/chat.css +171 -0
  117. package/web/chat.js +367 -0
@@ -0,0 +1,836 @@
1
+ // Pure HTML rendering for detail records — no IO, no state. Consumed by daemon's
2
+ // local /detail handler and the standalone svr binary. Any UI change here 自动
3
+ // 让两端保持同款外观。
4
+ import { structuredPatch, parsePatch } from "diff";
5
+ import { highlightCode, langFromPath } from "./highlight.js";
6
+ import { ansiToHtml } from "./ansi.js";
7
+ import { staleAt, turnDone } from "./chat-view.js";
8
+ const escHtml = (s) => s.replace(/[&<>"']/g, (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;");
9
+ const fmtTs = (ms) => {
10
+ const d = new Date(ms);
11
+ const pad = (n) => n.toString().padStart(2, "0");
12
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
13
+ };
14
+ const fmtDuration = (ms) => {
15
+ if (ms < 1000)
16
+ return `${ms}ms`;
17
+ const s = Math.round(ms / 100) / 10;
18
+ if (s < 60)
19
+ return `${s}s`;
20
+ const m = Math.floor(s / 60);
21
+ const rs = Math.round(s - m * 60);
22
+ return `${m}m${rs}s`;
23
+ };
24
+ // 42431 → "42.4k"; 999 → "999"; 12345678 → "12.35M"
25
+ const fmtTok = (n) => {
26
+ if (n < 1000)
27
+ return String(n);
28
+ if (n < 1e6) {
29
+ const v = n / 1000;
30
+ return (v >= 10 ? v.toFixed(0) : v.toFixed(1).replace(/\.0$/, "")) + "k";
31
+ }
32
+ return (n / 1e6).toFixed(2).replace(/\.?0+$/, "") + "M";
33
+ };
34
+ // token 分类配色 (统计卡片的堆叠条 + 图例共用)。
35
+ const TOK_COLORS = {
36
+ ctx: "#0969da", // 上下文峰值 (蓝)
37
+ input: "#0a7d6b", // 新鲜输入 (青绿)
38
+ cacheRead: "#8250df", // 缓存读 (紫)
39
+ cacheWrite: "#953800", // 缓存写 (棕)
40
+ output: "#1a7f37", // 输出 (绿)
41
+ };
42
+ const decisionBadge = (d) => {
43
+ if (!d)
44
+ return { label: "待审批", cls: "pending" };
45
+ if (d === "deny")
46
+ return { label: "拒绝", cls: "deny" };
47
+ if (d === "timeout")
48
+ return { label: "超时", cls: "warn" };
49
+ if (d === "allow_window")
50
+ return { label: "通过 · 窗口", cls: "allow" };
51
+ if (d === "allow_session")
52
+ return { label: "通过 · 会话", cls: "allow" };
53
+ if (d === "swept")
54
+ return { label: "通过 · 批量", cls: "allow" };
55
+ if (d === "allow")
56
+ return { label: "通过", cls: "allow" };
57
+ return { label: String(d), cls: "pending" };
58
+ };
59
+ const highlightJson = (json) => {
60
+ const escaped = escHtml(json);
61
+ return escaped.replace(/("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (_m, key, str, kw, num) => {
62
+ if (key)
63
+ return `<span class="jk">${key}</span>`;
64
+ if (str)
65
+ return `<span class="js">${str}</span>`;
66
+ if (kw)
67
+ return `<span class="jb">${kw}</span>`;
68
+ if (num)
69
+ return `<span class="jn">${num}</span>`;
70
+ return _m;
71
+ });
72
+ };
73
+ const toJson = (v) => {
74
+ try {
75
+ return JSON.stringify(v, null, 2) ?? "null";
76
+ }
77
+ catch {
78
+ return String(v);
79
+ }
80
+ };
81
+ export const SHARED_CSS = `
82
+ *{box-sizing:border-box}
83
+ html,body{margin:0;padding:0;background:#f6f8fa;color:#1f2328;
84
+ font:14px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Segoe UI",sans-serif}
85
+ .wrap{max-width:980px;margin:0 auto;padding:24px 20px 60px}
86
+ header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:8px}
87
+ h1{margin:0;font-size:18px;font-weight:600}
88
+ h1 .accent{color:#0969da}
89
+ .meta{color:#656d76;font-size:12px;margin-bottom:20px;
90
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}
91
+ .meta .sep{margin:0 6px;opacity:.5}
92
+ .badge{font-size:12px;padding:2px 8px;border-radius:4px;border:1px solid #d0d7de}
93
+ .badge.allow{color:#1a7f37;border-color:#1a7f3733;background:#1a7f3714}
94
+ .badge.deny{color:#cf222e;border-color:#cf222e33;background:#cf222e14}
95
+ .badge.warn{color:#9a6700;border-color:#9a670033;background:#9a670014}
96
+ .badge.pending{color:#8250df;border-color:#8250df33;background:#8250df14}
97
+ section{background:#fff;border:1px solid #d0d7de;border-radius:6px;
98
+ margin-bottom:12px;overflow:hidden}
99
+ section>h2{margin:0;padding:6px 12px;font-size:11px;font-weight:600;
100
+ color:#656d76;text-transform:uppercase;letter-spacing:.5px;
101
+ background:#f1f3f6;border-bottom:1px solid #d0d7de}
102
+ pre{margin:0;padding:12px;font-size:12.5px;line-height:1.5;
103
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
104
+ white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
105
+ .jk{color:#953800}.js{color:#0a3069}.jb{color:#9a6700}.jn{color:#8250df}
106
+ .hc{color:#6e7781;font-style:italic}.hs{color:#0a3069}.hk{color:#cf222e}
107
+ .hl{color:#8250df}.hn{color:#0550ae}.hv{color:#953800}
108
+ .codeview{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
109
+ font-size:12.5px;line-height:1.5;padding:8px 0}
110
+ .codeview .row{display:grid;grid-template-columns:56px 1fr;min-width:0}
111
+ .codeview .ln{color:#8c959f;text-align:right;padding:0 8px;
112
+ user-select:none;font-variant-numeric:tabular-nums}
113
+ .codeview .txt{padding:0 8px;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}
114
+ .diff{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
115
+ font-size:12.5px;line-height:1.5}
116
+ .diff .row{display:grid;grid-template-columns:44px 44px 14px 1fr;min-width:0}
117
+ .diff .row.add{background:#dafbe1}
118
+ .diff .row.del{background:#ffebe9}
119
+ .diff .row.hunk{background:#ddf4ff;color:#0969da;
120
+ grid-template-columns:1fr;padding:1px 12px}
121
+ .diff .ln{color:#8c959f;text-align:right;padding:0 6px;
122
+ user-select:none;font-variant-numeric:tabular-nums}
123
+ .diff .sign{text-align:center;color:#656d76;user-select:none}
124
+ .diff .row.add .sign{color:#1a7f37}
125
+ .diff .row.del .sign{color:#cf222e}
126
+ .diff .txt{padding:0 8px;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}
127
+ .diff-meta{padding:4px 12px;font-size:12px;color:#656d76;
128
+ background:#f1f3f6;border-bottom:1px solid #d0d7de;
129
+ font-family:ui-monospace,monospace}
130
+ details summary{cursor:pointer;color:#656d76;padding:6px 12px;font-size:11px;
131
+ text-transform:uppercase;letter-spacing:.5px;font-weight:600;
132
+ background:#f1f3f6;border-bottom:1px solid #d0d7de;
133
+ list-style:none;user-select:none}
134
+ details summary::-webkit-details-marker{display:none}
135
+ details summary::before{content:"▶ ";font-size:9px}
136
+ details[open] summary::before{content:"▼ "}
137
+ `;
138
+ const renderHunks = (hunks, label, path, lang) => {
139
+ let adds = 0, dels = 0;
140
+ const renderText = (t) => t === "" ? "&nbsp;" : (lang ? highlightCode(t, lang) : escHtml(t));
141
+ const rowsHtml = hunks.flatMap((h) => {
142
+ const header = `<div class="row hunk">@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@</div>`;
143
+ let oa = h.oldStart, nb = h.newStart;
144
+ const body = h.lines
145
+ .filter((ln) => !ln.startsWith("\\"))
146
+ .map((ln) => {
147
+ const sign = ln[0] ?? " ";
148
+ const text = ln.slice(1);
149
+ const tag = sign === "+" ? "add" : sign === "-" ? "del" : "eq";
150
+ const oldLn = tag === "add" ? "" : oa++;
151
+ const newLn = tag === "del" ? "" : nb++;
152
+ if (tag === "add")
153
+ adds++;
154
+ else if (tag === "del")
155
+ dels++;
156
+ return `<div class="row ${tag}"><div class="ln">${oldLn}</div><div class="ln">${newLn}</div><div class="sign">${sign}</div><div class="txt">${renderText(text)}</div></div>`;
157
+ })
158
+ .join("");
159
+ return [header, body];
160
+ }).join("");
161
+ const head = label ? `${escHtml(label)} · ` : "";
162
+ return `<section>
163
+ <h2>${head}<span style="color:#1a7f37">+${adds}</span> <span style="color:#cf222e">-${dels}</span>${path ? ` · <span style="color:#1f2328;text-transform:none;letter-spacing:0">${escHtml(path)}</span>` : ""}</h2>
164
+ <div class="diff">${rowsHtml}</div>
165
+ </section>`;
166
+ };
167
+ const renderDiffBlock = (b) => {
168
+ const norm = (s) => (s === "" || s.endsWith("\n") ? s : `${s}\n`);
169
+ const patch = structuredPatch("a", "b", norm(b.oldStr), norm(b.newStr), "", "", { context: 3 });
170
+ return renderHunks(patch.hunks, b.label, b.path, langFromPath(b.path));
171
+ };
172
+ const tryRenderUnifiedDiff = (text) => {
173
+ if (!/(^|\n)(diff --git |@@ -\d)/.test(text))
174
+ return "";
175
+ let patches;
176
+ try {
177
+ patches = parsePatch(text);
178
+ }
179
+ catch {
180
+ return "";
181
+ }
182
+ const sections = patches
183
+ .filter((p) => p.hunks && p.hunks.length > 0)
184
+ .map((p, idx, arr) => {
185
+ const path = p.newFileName || p.oldFileName || "";
186
+ const label = arr.length > 1 ? `file ${idx + 1}/${arr.length}` : undefined;
187
+ return renderHunks(p.hunks, label, path, langFromPath(path));
188
+ })
189
+ .join("");
190
+ return sections;
191
+ };
192
+ const extractDiffBlocks = (toolName, input) => {
193
+ if (!input || typeof input !== "object")
194
+ return [];
195
+ const i = input;
196
+ const path = typeof i.file_path === "string" ? i.file_path : "";
197
+ if (toolName === "Edit") {
198
+ const oldStr = typeof i.old_string === "string" ? i.old_string : "";
199
+ const newStr = typeof i.new_string === "string" ? i.new_string : "";
200
+ if (!oldStr && !newStr)
201
+ return [];
202
+ return [{ path, oldStr, newStr }];
203
+ }
204
+ if (toolName === "MultiEdit") {
205
+ const edits = Array.isArray(i.edits) ? i.edits : [];
206
+ return edits.flatMap((e, idx) => {
207
+ const oldStr = typeof e.old_string === "string" ? e.old_string : "";
208
+ const newStr = typeof e.new_string === "string" ? e.new_string : "";
209
+ if (!oldStr && !newStr)
210
+ return [];
211
+ return [{ path, oldStr, newStr, label: `edit ${idx + 1}/${edits.length}` }];
212
+ });
213
+ }
214
+ if (toolName === "Write") {
215
+ const content = typeof i.content === "string" ? i.content : "";
216
+ return [{ path, oldStr: "", newStr: content, label: "create / overwrite" }];
217
+ }
218
+ return [];
219
+ };
220
+ const renderBashCommand = (input) => {
221
+ if (!input || typeof input !== "object")
222
+ return "";
223
+ const i = input;
224
+ const cmd = typeof i.command === "string" ? i.command : "";
225
+ if (!cmd)
226
+ return "";
227
+ const desc = typeof i.description === "string" ? i.description : "";
228
+ const subtitle = desc ? ` · <span style="color:#1f2328;text-transform:none;letter-spacing:0;font-weight:400">${escHtml(desc)}</span>` : "";
229
+ return `<section><h2>command${subtitle}</h2><pre><code>${highlightCode(cmd, "bash")}</code></pre></section>`;
230
+ };
231
+ const CAT_N_RE = /^\s*(\d+)\t(.*)$/;
232
+ const renderReadContent = (text, filePath) => {
233
+ const lang = langFromPath(filePath);
234
+ const lines = text.split("\n");
235
+ if (lines.length > 0 && lines[lines.length - 1] === "")
236
+ lines.pop();
237
+ const rows = lines
238
+ .map((line) => {
239
+ const m = CAT_N_RE.exec(line);
240
+ const num = m ? m[1] : "";
241
+ const content = m ? m[2] : line;
242
+ const txt = content === "" ? "&nbsp;" : highlightCode(content, lang);
243
+ return `<div class="row"><div class="ln">${num}</div><div class="txt">${txt}</div></div>`;
244
+ })
245
+ .join("");
246
+ const langLabel = lang ? ` · <span style="color:#1f2328;text-transform:none;letter-spacing:0;font-weight:400">${escHtml(lang)}</span>` : "";
247
+ const head = filePath ? `<span style="color:#1f2328;text-transform:none;letter-spacing:0;font-weight:400">${escHtml(filePath)}</span>${langLabel}` : `content${langLabel}`;
248
+ return `<section><h2>${head}</h2><div class="codeview">${rows}</div></section>`;
249
+ };
250
+ const renderToolPage = (r) => {
251
+ const inputJson = highlightJson(toJson(r.toolInput));
252
+ const hasResult = typeof r.toolResult === "string" && r.toolResult.length > 0;
253
+ const isBash = r.toolName === "Bash";
254
+ const isRead = r.toolName === "Read";
255
+ const diffResultHtml = hasResult && !isRead ? tryRenderUnifiedDiff(r.toolResult) : "";
256
+ const filePath = isRead && r.toolInput && typeof r.toolInput === "object"
257
+ ? String(r.toolInput.file_path ?? "")
258
+ : "";
259
+ const readResultHtml = isRead && hasResult ? renderReadContent(r.toolResult, filePath) : "";
260
+ const resultBlock = readResultHtml
261
+ ? `${readResultHtml}<section><details><summary>result (raw)</summary><pre><code>${ansiToHtml(r.toolResult)}</code></pre></details></section>`
262
+ : hasResult
263
+ ? (diffResultHtml
264
+ ? `${diffResultHtml}<section><details><summary>result (raw)</summary><pre><code>${ansiToHtml(r.toolResult)}</code></pre></details></section>`
265
+ : `<section><h2>result</h2><pre><code>${ansiToHtml(r.toolResult)}</code></pre></section>`)
266
+ : `<section><h2>result</h2><pre style="color:#656d76;font-style:italic"><code>(尚未捕获)</code></pre></section>`;
267
+ const status = hasResult
268
+ ? `<span class="badge allow">完成${r.resultAt ? ` · ${fmtDuration(r.resultAt - r.createdAt)}` : ""}</span>`
269
+ : `<span class="badge pending">运行中</span>`;
270
+ const diffBlocks = extractDiffBlocks(r.toolName, r.toolInput);
271
+ const diffSection = diffBlocks.map(renderDiffBlock).join("");
272
+ const bashCommandHtml = isBash ? renderBashCommand(r.toolInput) : "";
273
+ const hasPrimary = diffBlocks.length > 0 || bashCommandHtml || readResultHtml;
274
+ const inputSection = hasPrimary
275
+ ? `<section><details><summary>input</summary><pre><code>${inputJson}</code></pre></details></section>`
276
+ : `<section><h2>input</h2><pre><code>${inputJson}</code></pre></section>`;
277
+ const metaParts = [
278
+ fmtTs(r.createdAt),
279
+ r.sessionId ? r.sessionId.slice(0, 8) : null,
280
+ r.target || null,
281
+ ].filter(Boolean);
282
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
283
+ <meta name="viewport" content="width=device-width,initial-scale=1">
284
+ <title>${escHtml(r.toolName)}</title>
285
+ <style>${SHARED_CSS}</style></head><body><div class="wrap">
286
+ <header><h1><span class="accent">${escHtml(r.toolName)}</span></h1>${status}</header>
287
+ <div class="meta">${metaParts.map(escHtml).join('<span class="sep">·</span>')}</div>
288
+ ${bashCommandHtml}
289
+ ${diffSection}
290
+ ${inputSection}
291
+ ${resultBlock}
292
+ </div></body></html>`;
293
+ };
294
+ const renderApprovalPage = (r) => {
295
+ const badge = decisionBadge(r.decision);
296
+ const inputJson = highlightJson(toJson(r.toolInput));
297
+ const ageMs = (r.decidedAt ?? Date.now()) - r.createdAt;
298
+ const transcript = r.transcriptTail.trim();
299
+ const metaParts = [
300
+ fmtTs(r.createdAt),
301
+ r.decidedAt ? `用时 ${fmtDuration(ageMs)}` : null,
302
+ r.decidedBy || null,
303
+ r.cwd || null,
304
+ ].filter(Boolean);
305
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
306
+ <meta name="viewport" content="width=device-width,initial-scale=1">
307
+ <title>${escHtml(r.toolName)}</title>
308
+ <style>${SHARED_CSS}</style></head><body><div class="wrap">
309
+ <header><h1><span class="accent">${escHtml(r.toolName)}</span></h1>
310
+ <span class="badge ${badge.cls}">${badge.label}</span></header>
311
+ <div class="meta">${metaParts.map(escHtml).join('<span class="sep">·</span>')}</div>
312
+ <section><h2>input</h2><pre><code>${inputJson}</code></pre></section>
313
+ ${transcript
314
+ ? `<section><details><summary>transcript (${transcript.split("\n").length} 行)</summary><pre><code>${ansiToHtml(transcript)}</code></pre></details></section>`
315
+ : ""}
316
+ </div></body></html>`;
317
+ };
318
+ // ── Turn (brief-mode) 聚合页 ────────────────────────────────────────────
319
+ // 一个 turn 内的所有 item 按 ts 序渲染成 claude.ai 风格气泡时间线。
320
+ // tool_use 与其配对的 tool_result 合并为一个可折叠 section (按 toolUseId 匹配)。
321
+ // assistant text 用客户端 markdown-it + highlight.js 富文本渲染 —— 服务端只输出
322
+ // 转义后的原文放到 data-md, 页尾脚本一次性 render 到 .md-body。未 closed 时页面
323
+ // 每 2s meta-refresh, closed 后移除 refresh + 状态徽章切「已完成 · 用时Xs」。
324
+ const TURN_CSS = `
325
+ .bubbles{display:flex;flex-direction:column;gap:14px;margin-top:8px}
326
+ .bubble{background:#fff;border:1px solid #d0d7de;border-radius:12px;overflow:hidden}
327
+ .bubble.assistant{background:#fff}
328
+ .bubble.final{border-color:#1a7f3766;box-shadow:0 0 0 2px #1a7f3714}
329
+ .bubble.approval{background:#fbfaff}
330
+ .bubble-head{display:flex;align-items:center;gap:8px;padding:10px 14px;
331
+ font-size:13px;color:#1f2328;flex-wrap:wrap}
332
+ .bubble-head .role{font-weight:600}
333
+ .bubble-head .ts{margin-left:auto;color:#8c959f;font-size:11px;
334
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
335
+ .bubble-head .compact{color:#656d76;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
336
+ font-size:12.5px;overflow:hidden;text-overflow:ellipsis;max-width:100%}
337
+ .md-body{padding:2px 16px 14px;color:#1f2328;line-height:1.65;font-size:14px}
338
+ .md-body p{margin:.6em 0}
339
+ .md-body h1,.md-body h2,.md-body h3{margin:1em 0 .4em;font-weight:600}
340
+ .md-body h1{font-size:1.4em}.md-body h2{font-size:1.2em}.md-body h3{font-size:1.05em}
341
+ .md-body ul,.md-body ol{margin:.6em 0;padding-left:1.6em}
342
+ .md-body li{margin:.2em 0}
343
+ .md-body code{background:#f6f8fa;border-radius:4px;padding:1px 5px;font-size:.9em;
344
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
345
+ .md-body pre{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;
346
+ margin:.7em 0;padding:12px;white-space:pre-wrap;overflow-wrap:anywhere}
347
+ .md-body pre code{background:transparent;padding:0;font-size:12.5px;line-height:1.5}
348
+ .md-body pre code.hljs{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
349
+ .md-body blockquote{margin:.6em 0;padding:.2em 1em;border-left:3px solid #d0d7de;color:#656d76}
350
+ .md-body table{border-collapse:collapse;margin:.7em 0}
351
+ .md-body th,.md-body td{border:1px solid #d0d7de;padding:6px 10px}
352
+ .md-body a{color:#0969da;text-decoration:none}
353
+ .md-body a:hover{text-decoration:underline}
354
+ .bubble details{border-top:1px solid #eaeef2}
355
+ .bubble details summary{background:#f6f8fa;border-bottom:0}
356
+ .typing{color:#8c959f;font-style:italic;padding:10px 14px;
357
+ background:#fff;border:1px dashed #d0d7de;border-radius:12px}
358
+ .typing::after{content:"";display:inline-block;width:6px;height:6px;
359
+ background:#8c959f;border-radius:50%;margin-left:6px;
360
+ animation:blink 1.2s infinite}
361
+ @keyframes blink{0%,60%,100%{opacity:.2}30%{opacity:1}}
362
+ .turn-info{display:flex;flex-wrap:wrap;gap:6px;margin:-6px 0 12px;align-items:center}
363
+ .turn-info:empty{display:none}
364
+ .chip.model{font-size:12px;padding:3px 10px;border-radius:12px;color:#0969da;
365
+ background:#0969da10;border:1px solid #0969da33;font-weight:600;
366
+ font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Segoe UI",sans-serif}
367
+ .chip.model .alt{opacity:.6;font-weight:400}
368
+ .chip.tier{font-size:11px;padding:2px 8px;border-radius:10px;color:#9a6700;
369
+ background:#9a670010;border:1px solid #9a670033;
370
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
371
+ /* ── 统计卡片 ── */
372
+ .stats{background:#fff;border:1px solid #d0d7de;border-radius:12px;
373
+ padding:18px 18px 16px;margin:0 0 16px;
374
+ box-shadow:0 1px 2px #1f23280a}
375
+ .stats-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));
376
+ gap:10px;margin-bottom:16px}
377
+ .tile{background:#f6f8fa;border:1px solid #eaeef2;border-radius:10px;
378
+ padding:12px 14px;display:flex;flex-direction:column;gap:3px}
379
+ .tile .k{font-size:11px;color:#8c959f;text-transform:uppercase;
380
+ letter-spacing:.5px;font-weight:600}
381
+ .tile .v{font-size:24px;font-weight:700;line-height:1.1;
382
+ font-variant-numeric:tabular-nums;color:#1f2328;
383
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
384
+ .tile .v .u{font-size:14px;font-weight:600;color:#8c959f;margin-left:1px}
385
+ .tile.accent .v{color:#0969da}
386
+ .stats-io .io-title{font-size:11px;color:#8c959f;text-transform:uppercase;
387
+ letter-spacing:.5px;font-weight:600;margin-bottom:8px}
388
+ .io-bar{display:flex;height:12px;border-radius:6px;overflow:hidden;
389
+ background:#eaeef2;margin-bottom:10px}
390
+ .io-bar .seg{height:100%}
391
+ .io-legend{display:flex;flex-wrap:wrap;gap:14px}
392
+ .io-legend .item{display:flex;align-items:center;gap:6px;font-size:12px;
393
+ color:#57606a}
394
+ .io-legend .dot{width:9px;height:9px;border-radius:3px;flex:none}
395
+ .io-legend .num{font-weight:700;color:#1f2328;
396
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
397
+ font-variant-numeric:tabular-nums}
398
+ /* ── 用户提问气泡 ── */
399
+ .bubble.user{background:#0969da0a;border-color:#0969da33}
400
+ .bubble.user .bubble-head{color:#0969da}
401
+ .bubble.user .q-body{padding:2px 16px 14px;color:#1f2328;line-height:1.6;
402
+ font-size:14px;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
403
+ /* ── 工具调用 (Claude CLI 风): 去卡片, 默认折叠, ⎿ 结果预览 ── */
404
+ .bubble.tool{background:transparent;border:0;border-radius:0;overflow:visible}
405
+ .bubble .tool-call{border:0}
406
+ .bubble .tool-call>summary{list-style:none;cursor:pointer;display:flex;
407
+ align-items:baseline;gap:7px;padding:3px 6px;border-radius:6px;border:0;
408
+ background:transparent;color:#1f2328;font-size:13.5px;font-weight:400;
409
+ text-transform:none;letter-spacing:0}
410
+ .bubble .tool-call>summary::-webkit-details-marker{display:none}
411
+ .bubble .tool-call>summary::before{content:none}
412
+ .bubble .tool-call>summary:hover{background:#f0f3f6}
413
+ .tool-dot{color:#1a7f37;font-size:11px;align-self:center;flex:none}
414
+ .tool-call[open]>summary .tool-dot{color:#0969da}
415
+ .tool-name{font-weight:600;flex:none;
416
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
417
+ .tool-arg{color:#656d76;min-width:0;flex:0 1 auto;
418
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
419
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
420
+ .tool-dur{color:#8c959f;font-size:11px;flex:none;
421
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
422
+ .tool-summary .ts{margin-left:auto;flex:none}
423
+ .tool-body{padding:8px 0 4px 16px;margin:2px 0 0 9px;
424
+ border-left:2px solid #eaeef2}
425
+ .tool-body>section{margin-bottom:8px}
426
+ .tool-body>section:last-child,.tool-body>details:last-child{margin-bottom:0}
427
+ .tool-result-line{padding:2px 6px 4px 24px;color:#57606a;font-size:12.5px;
428
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
429
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
430
+ .tool-call[open] ~ .tool-result-line{display:none}
431
+ .tool-result-line .corner{color:#b1bac4;margin-right:6px}
432
+ .tool-result-line .add{color:#1a7f37;font-weight:600}
433
+ .tool-result-line .del{color:#cf222e;font-weight:600}
434
+ .tool-result-line .more{color:#8c959f}
435
+ .tool-result-line .run{color:#9a6700}
436
+ `;
437
+ const renderJsonSection = (input) => `<details><summary>input</summary><pre><code>${highlightJson(toJson(input))}</code></pre></details>`;
438
+ // 结果行数 (末尾换行不计)。
439
+ const countLines = (s) => {
440
+ if (!s)
441
+ return 0;
442
+ const n = s.split("\n").length;
443
+ return s.endsWith("\n") ? n - 1 : n;
444
+ };
445
+ // Edit/Write/MultiEdit 的 +增 -删 汇总, 供折叠态 ⎿ 预览行用 (context:0 只数改动行)。
446
+ const diffStat = (blocks) => {
447
+ const norm = (s) => (s === "" || s.endsWith("\n") ? s : `${s}\n`);
448
+ return blocks.reduce((acc, b) => {
449
+ const p = structuredPatch("a", "b", norm(b.oldStr), norm(b.newStr), "", "", { context: 0 });
450
+ for (const h of p.hunks)
451
+ for (const ln of h.lines) {
452
+ if (ln.startsWith("+"))
453
+ acc.adds++;
454
+ else if (ln.startsWith("-"))
455
+ acc.dels++;
456
+ }
457
+ return acc;
458
+ }, { adds: 0, dels: 0 });
459
+ };
460
+ // Claude CLI 的 ⎿ 结果摘要: diff 工具→增删数, Read→行数, 其余→首行 + 行数。
461
+ const toolResultPreview = (use, diffBlocks, rawResult, hasResult) => {
462
+ if (!hasResult)
463
+ return `<span class="corner">⎿</span><span class="run">运行中…</span>`;
464
+ if (diffBlocks.length > 0) {
465
+ const { adds, dels } = diffStat(diffBlocks);
466
+ return `<span class="corner">⎿</span>更新 <span class="add">+${adds}</span> <span class="del">-${dels}</span>`;
467
+ }
468
+ if (use.toolName === "Read")
469
+ return `<span class="corner">⎿</span>读取 ${countLines(rawResult)} 行`;
470
+ const lines = countLines(rawResult);
471
+ const first = rawResult.split("\n").find((l) => l.trim() !== "") ?? "";
472
+ const head = escHtml(truncate(first.replace(/\s+/g, " ").trim(), 88));
473
+ const more = lines > 1 ? ` <span class="more">+${lines - 1} 行</span>` : "";
474
+ return `<span class="corner">⎿</span>${head || "(空)"}${more}`;
475
+ };
476
+ const renderToolBubble = (use, result, key) => {
477
+ const isBash = use.toolName === "Bash";
478
+ const isRead = use.toolName === "Read";
479
+ const bashHtml = isBash ? renderBashCommand(use.toolInput) : "";
480
+ const diffBlocks = extractDiffBlocks(use.toolName, use.toolInput);
481
+ const diffHtml = diffBlocks.map(renderDiffBlock).join("");
482
+ const rawResult = result?.body ?? "";
483
+ const readResultHtml = isRead && rawResult ? renderReadContent(rawResult, extractFilePath(use.toolInput)) : "";
484
+ const diffResultHtml = rawResult && !isRead ? tryRenderUnifiedDiff(rawResult) : "";
485
+ const primary = [bashHtml, diffHtml, readResultHtml, diffResultHtml].filter(Boolean).join("");
486
+ const inputSection = primary
487
+ ? renderJsonSection(use.toolInput)
488
+ : `<details open><summary>input</summary><pre><code>${highlightJson(toJson(use.toolInput))}</code></pre></details>`;
489
+ const resultRaw = rawResult
490
+ ? `<details><summary>result (raw)</summary><pre><code>${ansiToHtml(rawResult)}</code></pre></details>`
491
+ : `<details><summary>result</summary><pre style="color:#656d76;font-style:italic;margin:0;padding:12px"><code>(尚未捕获)</code></pre></details>`;
492
+ // 头部一行: ⏺ 工具名(参数) — 参数取命令/路径等主字段, 与 Claude CLI 同款。
493
+ const arg = oneLineCompact(use.toolInput, 72);
494
+ const dur = result ? `<span class="tool-dur">${escHtml(fmtDuration(result.ts - use.ts))}</span>` : "";
495
+ const preview = toolResultPreview(use, diffBlocks, rawResult, Boolean(result));
496
+ // 整个工具调用折叠进 <details> (默认收起); ⎿ 预览行是它的兄弟, 展开时 CSS 隐藏。
497
+ return `<section class="bubble tool" data-key="${key}">
498
+ <details class="tool-call">
499
+ <summary class="tool-summary"><span class="tool-dot">⏺</span><span class="tool-name">${escHtml(use.toolName)}</span>${arg ? `<span class="tool-arg">(${escHtml(arg)})</span>` : ""}${dur}<span class="ts">${fmtTs(use.ts)}</span></summary>
500
+ <div class="tool-body">${primary}${inputSection}${resultRaw}</div>
501
+ </details>
502
+ <div class="tool-result-line">${preview}</div>
503
+ </section>`;
504
+ };
505
+ const oneLineCompact = (input, max = 100) => {
506
+ if (input && typeof input === "object") {
507
+ const o = input;
508
+ const pick = o.command ?? o.file_path ?? o.path ?? o.pattern ?? o.url ?? o.query ?? o.prompt;
509
+ if (typeof pick === "string")
510
+ return truncate(pick.replace(/\s+/g, " ").trim(), max);
511
+ }
512
+ try {
513
+ return truncate(JSON.stringify(input) ?? "", max);
514
+ }
515
+ catch {
516
+ return "";
517
+ }
518
+ };
519
+ const truncate = (s, max) => s.length <= max ? s : `${s.slice(0, max - 1)}…`;
520
+ const extractFilePath = (input) => {
521
+ if (!input || typeof input !== "object")
522
+ return "";
523
+ const v = input.file_path;
524
+ return typeof v === "string" ? v : "";
525
+ };
526
+ const renderTextBubble = (item, key) => {
527
+ const role = item.final ? "assistant · final" : "assistant";
528
+ const cls = item.final ? "bubble final" : "bubble assistant";
529
+ // 原文用 <script type="text/plain"> 承载 —— 免转义歧义, JS 端 textContent 读回原样。
530
+ return `<section class="${cls}" data-key="${key}">
531
+ <div class="bubble-head"><span class="role">${escHtml(role)}</span>
532
+ <span class="ts">${fmtTs(item.ts)}</span></div>
533
+ <div class="md-body"></div>
534
+ <script type="text/plain" class="md-src">${escHtml(item.body)}</script>
535
+ </section>`;
536
+ };
537
+ const renderApprovalItem = (item, key) => {
538
+ const b = decisionBadge(item.decision);
539
+ return `<section class="bubble approval" data-key="${key}">
540
+ <div class="bubble-head">🔐 <span class="role">${escHtml(item.toolName)}</span>
541
+ <span class="badge ${b.cls}">${b.label}</span>
542
+ <span class="ts">${fmtTs(item.ts)}</span></div>
543
+ </section>`;
544
+ };
545
+ const pairItems = (items) => {
546
+ const results = new Map();
547
+ for (const it of items)
548
+ if (it.t === "tool_result")
549
+ results.set(it.toolUseId, it);
550
+ const paired = [];
551
+ const consumed = new Set();
552
+ for (const it of items) {
553
+ if (it.t === "tool_use") {
554
+ const r = results.get(it.toolUseId);
555
+ if (r)
556
+ consumed.add(it.toolUseId);
557
+ paired.push({ kind: "pair", use: it, result: r });
558
+ }
559
+ else if (it.t === "tool_result") {
560
+ if (consumed.has(it.toolUseId))
561
+ continue; // 已附在 tool_use bubble 里
562
+ paired.push({ kind: "solo", item: it });
563
+ }
564
+ else {
565
+ paired.push({ kind: "solo", item: it });
566
+ }
567
+ }
568
+ return paired;
569
+ };
570
+ // token 数 → "42.4k" 拆成 {n, u} 供 tile 用大数字 + 小单位分开排版。
571
+ const splitTok = (n) => {
572
+ const s = fmtTok(n);
573
+ const m = /^([\d.]+)([kM]?)$/.exec(s);
574
+ return m ? { n: m[1], u: m[2] } : { n: s, u: "" };
575
+ };
576
+ const tile = (label, n, accent = false) => {
577
+ const { n: num, u } = splitTok(n);
578
+ return `<div class="tile${accent ? " accent" : ""}"><span class="k">${label}</span>
579
+ <span class="v">${num}${u ? `<span class="u">${u}</span>` : ""}</span></div>`;
580
+ };
581
+ // 累计 token I/O 的堆叠条 + 图例。上下文峰值单列一 tile, 这里画的是「总共读写了多少」
582
+ // 的计费口径 (cacheRead 常远大于窗口占用, 属正常 —— 每次调用重读同一前缀累加)。
583
+ const renderStats = (u, ctxPeak, ageMs, done) => {
584
+ const allSegs = [
585
+ { key: "input", label: "Input", n: u.input },
586
+ { key: "cacheRead", label: "Cache read", n: u.cacheRead },
587
+ { key: "cacheWrite", label: "Cache write", n: u.cacheWrite },
588
+ { key: "output", label: "Output", n: u.output },
589
+ ];
590
+ const segs = allSegs.filter((s) => s.n > 0);
591
+ const total = segs.reduce((a, s) => a + s.n, 0) || 1;
592
+ const bar = segs
593
+ .map((s) => `<span class="seg" style="width:${((s.n / total) * 100).toFixed(2)}%;background:${TOK_COLORS[s.key]}" title="${s.label}: ${fmtTok(s.n)}"></span>`)
594
+ .join("");
595
+ const legend = segs
596
+ .map((s) => `<span class="item"><span class="dot" style="background:${TOK_COLORS[s.key]}"></span>${s.label} <span class="num">${fmtTok(s.n)}</span></span>`)
597
+ .join("");
598
+ const tiles = [
599
+ tile("Context", ctxPeak, true),
600
+ tile("Output", u.output),
601
+ `<div class="tile"><span class="k">API calls</span><span class="v">${u.calls}</span></div>`,
602
+ `<div class="tile"><span class="k">${done ? "Duration" : "Elapsed"}</span><span class="v" style="font-size:20px">${escHtml(fmtDuration(ageMs))}</span></div>`,
603
+ ].join("");
604
+ return `<section class="stats">
605
+ <div class="stats-tiles">${tiles}</div>
606
+ <div class="stats-io">
607
+ <div class="io-title">Token I/O · cumulative</div>
608
+ <div class="io-bar">${bar}</div>
609
+ <div class="io-legend">${legend}</div>
610
+ </div>
611
+ </section>`;
612
+ };
613
+ // djb2 → base36. reconcile 用它做「气泡内容变没变」的判据 —— 不用 outerHTML, 因为客户端
614
+ // 会往 text 气泡的 .md-body 里填渲染结果, 污染 outerHTML; data-sig 是服务端算的纯内容指纹。
615
+ const hashStr = (s) => {
616
+ let h = 5381;
617
+ for (let i = 0; i < s.length; i++)
618
+ h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
619
+ return h.toString(36);
620
+ };
621
+ // 在气泡根 tag 的 data-key 后补一个 data-sig=内容指纹 (指纹基于未含 sig 的原 html)。
622
+ const tagSig = (html) => html ? html.replace(/(data-key="[^"]*")/, `$1 data-sig="${hashStr(html)}"`) : "";
623
+ // 一个 turn 的可复用切片: 排序后的 items、渲染好的气泡、完成态与耗时。
624
+ // 单 turn 页 (renderTurnPage) 和 chat 线程页 (renderTurnGroup) 共用同一份产物 ——
625
+ // keyPrefix 让线程页里多个 turn 的 data-key 不互撞。
626
+ const turnParts = (r, keyPrefix = "", now = Date.now()) => {
627
+ const items = [...r.items].sort((a, b) => a.ts - b.ts);
628
+ const paired = pairItems(items);
629
+ // data-key = 稳定项键 (items 只追加、按 ts 单调, 位置永不前移 → 索引稳定唯一)。
630
+ // 客户端 reconcile 按此键复用未变气泡的 DOM 节点, 从而保住用户手动展开/折叠的 <details>。
631
+ const bodies = paired.map((p, i) => {
632
+ const key = `${keyPrefix}b${i}`;
633
+ if (p.kind === "pair")
634
+ return renderToolBubble(p.use, p.result, key);
635
+ const it = p.item;
636
+ if (it.t === "text")
637
+ return renderTextBubble(it, key);
638
+ if (it.t === "approval")
639
+ return renderApprovalItem(it, key);
640
+ if (it.t === "tool_result") {
641
+ return `<section class="bubble tool" data-key="${key}">
642
+ <div class="bubble-head">↩ <span class="role">tool_result</span>
643
+ <span class="compact">${escHtml(it.toolUseId)}</span>
644
+ <span class="ts">${fmtTs(it.ts)}</span></div>
645
+ <details open><summary>result</summary><pre><code>${escHtml(it.body)}</code></pre></details>
646
+ </section>`;
647
+ }
648
+ return "";
649
+ });
650
+ // 结束判定统一收在 chat-view.turnDone (closed / final text / 静默超时), 详情页
651
+ // 与聊天视图必须给出同一个答案 —— 否则一个显示「进行中」另一个显示「已完成」。
652
+ const done = turnDone(r, now);
653
+ return { items, bodies, done, ageMs: (done ? r.updatedAt : now) - r.createdAt };
654
+ };
655
+ const renderTurnPage = (r) => {
656
+ const { items, bodies, done, ageMs } = turnParts(r);
657
+ const statusBadge = done
658
+ ? `<span class="badge allow">已完成 · ${fmtDuration(ageMs)}</span>`
659
+ : `<span class="badge pending">进行中</span>`;
660
+ const modelChip = r.model
661
+ ? `<span class="chip model">${escHtml(r.model)}${r.modelAlt ? `<span class="alt"> +${r.modelAlt}</span>` : ""}</span>`
662
+ : "";
663
+ const u = r.usage;
664
+ // 上下文 = 单次 API 调用送入的 input+缓存读+缓存写 的峰值 (窗口占用高水位), 与下方
665
+ // 累计 I/O 语义不同。窗口上限/占比不再展示 —— 长上下文变体口径不准, 易误导。
666
+ const ctxPeak = u ? (u.ctxPeak ?? (u.input + u.cacheRead + u.cacheWrite)) : 0;
667
+ const tierChip = u?.serviceTier && u.serviceTier !== "standard"
668
+ ? `<span class="chip tier">${escHtml(u.serviceTier)}</span>` : "";
669
+ const turnInfo = modelChip || tierChip ? `<div class="turn-info">${modelChip}${tierChip}</div>` : "";
670
+ const statsCard = u ? renderStats(u, ctxPeak, ageMs, done) : "";
671
+ const queryBubble = r.userQuery
672
+ ? `<section class="bubble user" data-key="user">
673
+ <div class="bubble-head">💬 <span class="role">User</span></div>
674
+ <div class="q-body">${escHtml(r.userQuery)}</div>
675
+ </section>`
676
+ : "";
677
+ const metaParts = [
678
+ fmtTs(r.createdAt),
679
+ r.sessionId ? r.sessionId.slice(0, 8) : null,
680
+ r.target || null,
681
+ `${items.length} 项`,
682
+ ].filter(Boolean);
683
+ const typing = done ? "" : `<div class="typing" data-key="typing">Claude 正在思考</div>`;
684
+ // markdown-it + highlight.js from unpkg CDN. html:false 防注入; linkify + breaks 更贴聊天。
685
+ // 未 closed 时客户端 2s 轮询同一 URL — DOMParser 抽 .bubbles 后按 data-key reconcile:
686
+ // 未变气泡复用原 DOM 节点 (连带用户手动展开/折叠的 <details> 一并保住), 变化/新增气泡
687
+ // 才换成新节点、并从快照回填用户改过的折叠态。整页不重刷 → 保留滚动位置 + CDN 缓存。
688
+ // closed 时 body[data-closed=1], 轮询自终止。
689
+ const script = `
690
+ (function(){
691
+ var render = function(scope){
692
+ if(!window.markdownit) return;
693
+ var hl = function(str,lang){
694
+ if(lang && window.hljs){
695
+ try{return window.hljs.highlight(str,{language:lang,ignoreIllegals:true}).value}catch(e){}
696
+ }
697
+ if(window.hljs){try{return window.hljs.highlightAuto(str).value}catch(e){}}
698
+ return '';
699
+ };
700
+ var md = window.markdownit({html:false, linkify:true, breaks:true, highlight:hl});
701
+ scope.querySelectorAll('.bubble').forEach(function(b){
702
+ var src = b.querySelector('script.md-src');
703
+ var body = b.querySelector('.md-body');
704
+ // 已渲染过的复用节点跳过 —— 免闪一下, 也不动其内部状态。
705
+ if(src && body && body.dataset.rendered !== '1'){
706
+ body.innerHTML = md.render(src.textContent||'');
707
+ body.dataset.rendered = '1';
708
+ }
709
+ });
710
+ };
711
+ // key#idx → <details>.open 快照, 供换新节点后回填用户手动折叠态。
712
+ var snapOpen = function(scope){
713
+ var m = {};
714
+ scope.querySelectorAll('[data-key]').forEach(function(b){
715
+ var k = b.getAttribute('data-key');
716
+ b.querySelectorAll('details').forEach(function(d,i){ m[k+'#'+i] = d.open; });
717
+ });
718
+ return m;
719
+ };
720
+ var restoreOpen = function(scope, m){
721
+ scope.querySelectorAll('[data-key]').forEach(function(b){
722
+ var k = b.getAttribute('data-key');
723
+ b.querySelectorAll('details').forEach(function(d,i){
724
+ var v = m[k+'#'+i]; if(v !== undefined) d.open = v;
725
+ });
726
+ });
727
+ };
728
+ // 按 data-key diff: data-sig (服务端内容指纹) 相同则原样保留旧节点, 否则采纳新节点。
729
+ var reconcile = function(cur, next){
730
+ var open = snapOpen(cur);
731
+ var existing = {};
732
+ Array.prototype.forEach.call(cur.children, function(c){
733
+ var k = c.getAttribute('data-key'); if(k) existing[k] = c;
734
+ });
735
+ var nodes = Array.prototype.map.call(next.children, function(nc){
736
+ var k = nc.getAttribute('data-key');
737
+ var ex = k ? existing[k] : null;
738
+ var same = ex && ex.getAttribute('data-sig') === nc.getAttribute('data-sig');
739
+ return same ? ex : document.importNode(nc, true);
740
+ });
741
+ cur.replaceChildren.apply(cur, nodes);
742
+ restoreOpen(cur, open);
743
+ render(cur);
744
+ };
745
+ render(document);
746
+ if(document.body.dataset.closed === '1') return;
747
+ var url = location.href;
748
+ var tick = function(){
749
+ fetch(url, {cache:'no-store'}).then(function(r){return r.text()}).then(function(html){
750
+ var doc = new DOMParser().parseFromString(html, 'text/html');
751
+ var newBubbles = doc.querySelector('.bubbles');
752
+ var curBubbles = document.querySelector('.bubbles');
753
+ if(newBubbles && curBubbles){
754
+ reconcile(curBubbles, newBubbles);
755
+ }
756
+ var newBadge = doc.querySelector('header .badge');
757
+ var curBadge = document.querySelector('header .badge');
758
+ if(newBadge && curBadge){ curBadge.outerHTML = newBadge.outerHTML; }
759
+ var newInfo = doc.querySelector('.turn-info');
760
+ var curInfo = document.querySelector('.turn-info');
761
+ if(newInfo && curInfo){ curInfo.outerHTML = newInfo.outerHTML; }
762
+ var newStats = doc.querySelector('.stats');
763
+ var curStats = document.querySelector('.stats');
764
+ if(newStats && curStats){ curStats.outerHTML = newStats.outerHTML; }
765
+ else if(newStats && curBubbles){ curBubbles.parentNode.insertBefore(newStats, curBubbles); }
766
+ else if(newStats){ document.querySelector('.wrap').appendChild(newStats); }
767
+ var nowClosed = doc.body.dataset.closed === '1';
768
+ if(nowClosed){ document.body.dataset.closed = '1'; return; }
769
+ setTimeout(tick, 2000);
770
+ }).catch(function(){ setTimeout(tick, 2000); });
771
+ };
772
+ setTimeout(tick, 2000);
773
+ })();
774
+ `;
775
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
776
+ <meta name="viewport" content="width=device-width,initial-scale=1">
777
+ <title>Turn Details</title>
778
+ <link rel="stylesheet" href="https://unpkg.com/highlight.js@11/styles/github.min.css">
779
+ <style>${SHARED_CSS}${TURN_CSS}</style>
780
+ <script src="https://unpkg.com/markdown-it@14/dist/markdown-it.min.js"></script>
781
+ <script src="https://unpkg.com/@highlightjs/cdn-assets@11/highlight.min.js"></script>
782
+ </head><body data-closed="${done ? "1" : "0"}"><div class="wrap">
783
+ <header><h1><span class="accent">Turn Details</span></h1>${statusBadge}</header>
784
+ ${turnInfo}
785
+ <div class="meta">${metaParts.map(escHtml).join('<span class="sep">·</span>')}</div>
786
+ ${statsCard}
787
+ <div class="bubbles">${[queryBubble, ...bodies, typing].map(tagSig).join("")}</div>
788
+ </div>
789
+ <script>${script}</script>
790
+ </body></html>`;
791
+ };
792
+ export const renderTurnGroup = (r, now = Date.now()) => {
793
+ const { items, bodies, done, ageMs } = turnParts(r, `${r.id}:`, now);
794
+ const u = r.usage;
795
+ const ctxPeak = u ? (u.ctxPeak ?? u.input + u.cacheRead + u.cacheWrite) : 0;
796
+ const chips = [
797
+ r.model ? `<span class="chip model">${escHtml(r.model)}${r.modelAlt ? `<span class="alt"> +${r.modelAlt}</span>` : ""}</span>` : "",
798
+ u?.serviceTier && u.serviceTier !== "standard" ? `<span class="chip tier">${escHtml(u.serviceTier)}</span>` : "",
799
+ ctxPeak ? `<span class="tg-tok" title="上下文峰值">ctx ${fmtTok(ctxPeak)}</span>` : "",
800
+ u?.output ? `<span class="tg-tok" title="输出 token">out ${fmtTok(u.output)}</span>` : "",
801
+ // 耗时只在收口后写死 —— 进行中的 turn 每次渲染都会得到不同的 ageMs, 会把 sig 打乱,
802
+ // 让 SSE 的"内容没变就不重发"彻底失效。进行中的时长由页脚 status bar 负责。
803
+ done ? `<span class="tg-tok">${escHtml(fmtDuration(ageMs))}</span>` : "",
804
+ ].filter(Boolean).join("");
805
+ const queryBubble = r.userQuery
806
+ ? `<section class="bubble user" data-key="${r.id}:user">
807
+ <div class="bubble-head">💬 <span class="role">User</span></div>
808
+ <div class="q-body">${escHtml(r.userQuery)}</div>
809
+ </section>`
810
+ : "";
811
+ const typing = done ? "" : `<div class="typing" data-key="${r.id}:typing">Claude 正在思考</div>`;
812
+ const inner = [queryBubble, ...bodies, typing].map(tagSig).join("");
813
+ const head = `<div class="tg-head">
814
+ <span class="tg-dot${done ? "" : " live"}"></span>
815
+ <span class="tg-time">${fmtTs(r.createdAt)}</span>${chips}
816
+ <a class="tg-link" href="detail?id=${encodeURIComponent(r.id)}" target="_blank" title="${items.length} 项">↗</a>
817
+ </div>`;
818
+ // staleAt 只走 JSON, 绝不进 HTML —— 它跟着 updatedAt 变, 一旦计入 sig, SSE 的
819
+ // "内容没变就不重发" 会彻底失效 (一个 turn 的 HTML 可以是几十 KB)。
820
+ const body = `<section class="turn-group" data-key="t:${escHtml(r.id)}">${head}<div class="bubbles">${inner}</div></section>`;
821
+ return {
822
+ id: r.id, html: tagSig(body), sig: hashStr(body),
823
+ createdAt: r.createdAt, updatedAt: r.updatedAt,
824
+ done, staleAt: done ? 0 : staleAt(r),
825
+ };
826
+ };
827
+ export { escHtml, fmtTs, fmtDuration, fmtTok, TURN_CSS };
828
+ export const renderDetailPage = (r) => {
829
+ if (r.kind === "tool")
830
+ return renderToolPage(r);
831
+ if (r.kind === "turn")
832
+ return renderTurnPage(r);
833
+ return renderApprovalPage(r);
834
+ };
835
+ export const renderNotFound = (id) => `<!doctype html><meta charset="utf-8"><body style="font:14px -apple-system,sans-serif;background:#f6f8fa;color:#1f2328;padding:60px 20px;text-align:center"><p style="color:#656d76">未找到 <code style="background:#fff;border:1px solid #d0d7de;border-radius:4px;padding:2px 6px;font-family:ui-monospace,monospace">${escHtml(id)}</code></p></body>`;
836
+ //# sourceMappingURL=detail-render.js.map