@migor/cli 0.10.8 → 0.10.10

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.
@@ -218,6 +218,61 @@
218
218
  min-width: 0;
219
219
  }
220
220
 
221
+ /* —— 时间轴(2026-11-13):树行右侧一条按 run 总时长归一化的细轨 ——
222
+ 结构视角看不出「谁和谁并行 / 8 秒被谁吃掉 / 首个 token 等了多久」,而每个 span 的
223
+ 起止本来就在数据里。最小形态 = 同一套行右侧加一条轨道,不引入第二套布局(瀑布图那种
224
+ 双栏会把已经够挤的树再压窄一半)。
225
+
226
+ `margin-left: auto` 让「读数 — 轨道」贴在一起:两者说的是同一件事(这个 span 花了多久)。
227
+ 条本身用 span 的绝对几何(`left` / `width` 百分比),两端都在 JS 里 clamp 过。 */
228
+ .tr-tl {
229
+ flex: none;
230
+ margin-left: auto;
231
+ width: 72px;
232
+ height: 5px;
233
+ position: relative;
234
+ background: #1b1b1f;
235
+ border-radius: 3px;
236
+ }
237
+ .tr-tl-bar {
238
+ position: absolute;
239
+ top: 0;
240
+ bottom: 0;
241
+ background: var(--ice);
242
+ opacity: 0.45;
243
+ border-radius: 3px;
244
+ }
245
+ .tr-row.error .tr-tl-bar {
246
+ background: #f7768e;
247
+ opacity: 0.6;
248
+ }
249
+ /* 有轨道的行:meta 不再吃 auto 边距(否则轨道被推到名字旁边、读数留在最右,两者脱节) */
250
+ .tr-row[data-tl] .tr-meta {
251
+ margin-left: 0;
252
+ padding-left: 8px;
253
+ }
254
+ /* span 行的 caret 与事件行同一个字形,但行内没有 `.tr-io` 可以让位 ⇒ 行自己留一条槽 */
255
+ .tr-row[data-expandable]:not(.tr-ev) {
256
+ padding-right: 14px;
257
+ }
258
+ /* 窄屏(面板退成单列时)把轨道收起来:72px 在那个宽度里是从名字里抢的 */
259
+ @media (max-width: 860px) {
260
+ .tr-tl {
261
+ display: none;
262
+ }
263
+ }
264
+
265
+ /* —— 树内检索(2026-11-13):**只标记,不改结构** ——
266
+ 裁剪节点会让「谁挂在谁下面」这个事实失真(隐藏父节点后子节点看起来属于别人),
267
+ 所以命中行只加底色;`-cur` 是「刚才跳到的那一处」,用来在连按 Enter 时不迷路。 */
268
+ .tr-hit {
269
+ background: rgba(224, 175, 104, 0.13);
270
+ }
271
+ .tr-hit-cur {
272
+ background: rgba(224, 175, 104, 0.28);
273
+ box-shadow: inset 2px 0 0 var(--warn, #e0af68);
274
+ }
275
+
221
276
  /* —— 结构化正文(2026-11-11):`full` 是对象/数组时,展开行**下方**多一个兄弟块 ——
222
277
  此前只有一条展开路:整串无缩进的 JSON 塞进 `.tr-io` 再靠 `.tr-open` 换行,20 个键就在
223
278
  400px 宽的右栏里折成十几行连续文本(页面不报错、无溢出,只是人读不了)。
@@ -296,6 +351,17 @@
296
351
  padding: 0 4px;
297
352
  margin-right: 4px;
298
353
  }
354
+ /* `md`:这段正文被 Markdown 渲染过(2026-11-12)。与 `str→json` 同一套外观 ——
355
+ 两者都是「你看到的不是原样」的告示,样式分叉只会让人以为它们是不同性质的东西。 */
356
+ .tr-full-md {
357
+ color: var(--faint);
358
+ font-size: 10px;
359
+ line-height: 1.4;
360
+ border: 1px solid #2c2c32;
361
+ border-radius: 3px;
362
+ padding: 0 4px;
363
+ margin-right: 4px;
364
+ }
299
365
  /* `⧉` 复制路径:hover 才露。与 .tr-caret 的区别是**它暴露的是动作、不是信息** ——
300
366
  路径的内容(键名)始终写在行里,常显只会给每行多一个字形噪声。
301
367
  没有剪贴板实现时照样显示、点了不谎报成功(见 full-view.js 的 copyInto)。 */
@@ -17,11 +17,14 @@
17
17
  * 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
18
18
  */
19
19
 
20
- /* 展开态的正文有两种形状,由 `full` 的类型决定(2026-11-11,2026-11-12 扩了一档):
20
+ /* 展开态的正文有三种形状(2026-11-11,2026-11-12 扩了两档):
21
21
  * - 纯文本字符串 → 老路:`.tr-io` 里放原文,靠 `.tr-open` 换行(纯文本正文、官网模拟回放);
22
22
  * - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS 隐藏;
23
23
  * - **装着 JSON 的字符串**(`content: "{\"rows\":[...]}"`,工具把结构序列化后塞回文本字段)
24
- * → 同上一档,块头写明「string · N 字符 → object · M 项」并打 `str→json` 标记。
24
+ * → 同上一档,块头写明「string · N 字符 → object · M 项」并打 `str→json` 标记;
25
+ * - **一整篇 Markdown 的字符串**(工具出参就是一份 SKILL.md / 报告)→ 由宿主注入的
26
+ * `opts.textBody(text)` 渲染成正文块,块头打 `md` 标记。注入是刻意的:本包是独立
27
+ * 发布单元,解析器只有一份(在宿主里),两边各带一份必然漂移。
25
28
  * 判据与块内完全同源(`hasStructuredBody`),「挂不挂块」与「块里画什么」因此不会跑偏。
26
29
  * 前两档并存是刻意的:纯文本那条路有 test/style.test.js 的两个不变量守着(最小宽度、caret 常显),
27
30
  * 全换成本件会让那两个守卫失去落点。 */
@@ -91,10 +94,31 @@ export function createTraceView(rootEl, opts = {}) {
91
94
  let price = opts.price ?? null;
92
95
  const onUsage = opts.onUsage ?? null;
93
96
  const usageEls = opts.usage ?? null;
97
+ /* 字符串正文的宿主渲染器(可选):`(text) => Element | null`。返回 null = 「不是那种
98
+ 正文,保持原文」。本包不自带 Markdown 解析器 —— 见文件头注。 */
99
+ const textBody = typeof opts.textBody === 'function' ? opts.textBody : null;
94
100
 
95
101
  let traceRoot = null;
96
102
  const spanMap = new Map();
97
103
  const usageAcc = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
104
+ /* 批量渲染(2026-11-13)。此前每来一条 span 动作就整树重建一次 ⇒ `playTrace` 是 O(N²):
105
+ 实测 50 span 触发 151 次全树重建、600 span 触发 1801 次(假 DOM 就 172ms),而在飞时
106
+ `renderLive` 每个 rAF 还会再调一次 `playTrace`。这不是「慢一点」,是长跑时面板直接卡死。
107
+ 语义不变:**渲染仍是全量重建**(那是折叠态必须外部持有的原因),只是把「一批动作之间
108
+ 的 N 次重建」压成「结束后一次」。
109
+ ⚠️ 嵌套是安全的(计数器),且批内抛错也会在 finally 里补画 —— 否则异常路径之后整棵树
110
+ 停在中间状态,而下一条事件又会被 `dirty` 吞掉。 */
111
+ let batchDepth = 0;
112
+ let batchDirty = false;
113
+ /* 树内检索(2026-11-13):**不改结构**,只给命中的行加类 + 提供「跳到下一处」。
114
+ 为什么不裁剪节点:树形是「谁挂在谁下面」的事实,隐藏父节点会让子节点看起来属于别人;
115
+ 而裁剪后「有多少条被藏起来了」又要另一处交代。高亮 + 跳转在不改事实的前提下解决
116
+ 「一百个 span 里找那一条」。 */
117
+ let query = '';
118
+ let hitIndex = -1;
119
+ /* 时间轴开关(2026-11-13):轨道占 72px,窄面板里是从名字里抢的 ⇒ 允许关掉。
120
+ 默认开 —— 它是「谁和谁并行 / 8 秒被谁吃掉」唯一的落点,而那条信息此前完全没有。 */
121
+ let timeline = true;
98
122
  /* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
99
123
  渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
100
124
  const expanded = new Set();
@@ -118,8 +142,66 @@ export function createTraceView(rootEl, opts = {}) {
118
142
  renderTrace();
119
143
  }
120
144
 
145
+ /** 宿主渲染器包一层:**只在展开时调用**(折叠态每条事件都会重画,挂在那儿等于白解析
146
+ * 一遍所有历史正文);宿主抛错也不许让整棵树白屏 —— 回落原文即可。 */
147
+ function textBodyOf(v) {
148
+ if (!textBody || typeof v !== 'string') return null;
149
+ try {
150
+ return textBody(v) || null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /** 把一批渲染动作压成一次重画(见 `batchDepth` 的头注)。没在批里就直接画。 */
121
157
  function renderTrace() {
158
+ if (batchDepth > 0) {
159
+ batchDirty = true;
160
+ return;
161
+ }
162
+ drawTree();
163
+ }
164
+
165
+ function batch(fn) {
166
+ batchDepth += 1;
167
+ try {
168
+ fn();
169
+ } finally {
170
+ batchDepth -= 1;
171
+ // 批内一次都没标脏(例如只读了状态)⇒ 不白画一遍
172
+ if (batchDepth === 0 && batchDirty) {
173
+ batchDirty = false;
174
+ drawTree();
175
+ }
176
+ }
177
+ }
178
+
179
+ /** 检索:这一行的文本是否命中。空查询恒 false(「没在搜」不等于「全都命中」) */
180
+ function hits(texts) {
181
+ if (!query) return false;
182
+ const q = query.toLowerCase();
183
+ for (const t of texts) if (t && String(t).toLowerCase().includes(q)) return true;
184
+ return false;
185
+ }
186
+
187
+ function drawTree() {
122
188
  rootEl.innerHTML = '';
189
+ hitIndex = -1;
190
+ // 还没 reset 过(视图刚建出来):清空即返回。setTimeline / setQuery 可能先于第一棵树
191
+ // 被调用 —— 没有这一条,`traceRoot.order` 会抛,而调用方只是在设一个开关。
192
+ if (!traceRoot) return;
193
+ /* 时间轴的归一化基准:根 span 的起点与整棵树的终点。两者缺一就**不画条**
194
+ (手写 trace / 官网模拟回放没有时间戳 —— 画一条人人占满的条比不画更误导)。 */
195
+ const t0 = traceRoot && typeof traceRoot.startedAt === 'number' ? traceRoot.startedAt : null;
196
+ let tMax = t0;
197
+ if (t0 !== null) {
198
+ for (const n of spanMap.values()) {
199
+ const e = typeof n.endAt === 'number' ? n.endAt : n.startedAt;
200
+ if (typeof e === 'number' && e > tMax) tMax = e;
201
+ }
202
+ }
203
+ const total = t0 !== null && tMax !== null ? Math.max(0, tMax - t0) : 0;
204
+
123
205
  const rows = [];
124
206
  /* 每个节点下【事件】与【子 span】按发生顺序混排:tool.input 先于它触发的 capability span、
125
207
  tool.output 后于它,这个先后本身就是语义,不能拍平成一类。 */
@@ -142,6 +224,11 @@ export function createTraceView(rootEl, opts = {}) {
142
224
  const open = expanded.has(ev.key);
143
225
  const row = el('div', 'tr-row tr-ev' + (ev.ok === false ? ' error' : ''));
144
226
  row.dataset.ev = ev.type;
227
+ /* 检索只扫摘要与类型/工具名,**不扫完整正文**:`full` 常见 10KB 级,每帧每行
228
+ indexOf 一遍会让「搜索」本身成为掉帧的原因(600 行 × 10KB ≈ 6MB/帧)。
229
+ 2000 字符以内才扫 —— 那个尺度上全文搜索才有意义(再长就是文档,不是一行)。 */
230
+ const fullShort = typeof ev.full === 'string' && ev.full.length <= 2000 ? ev.full : '';
231
+ if (hits([ev.type, ev.tool, ev.text, fullShort])) row.className += ' tr-hit';
145
232
  row.dataset.capability = capabilityTypeOf(ev.tool);
146
233
  row.appendChild(el('span', 'tr-pre', branch));
147
234
  row.appendChild(el('span', 'tr-evv', ev.type === 'tool.output' ? '◂' : '▸'));
@@ -150,11 +237,13 @@ export function createTraceView(rootEl, opts = {}) {
150
237
  // 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
151
238
  if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
152
239
  // 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致。
153
- // `full` 是对象/数组、或**装着 JSON 的字符串**时(2026-11-11 / 2026-11-12):`.tr-io`
154
- // 仍放**原文**(结构化块挂在行后、由 CSS 隐藏本格),所以「展开拿到的是原文而不是
155
- // 62 字符摘要」这条不变量原样成立。
240
+ // `full` 是对象/数组、装着 JSON 的字符串、或宿主认得的正文(Markdown)时
241
+ // (2026-11-11 / 2026-11-12):`.tr-io` 仍放**原文**(结构化块挂在行后、由 CSS 隐藏本格),
242
+ // 所以「展开拿到的是原文而不是 62 字符摘要」这条不变量原样成立。
156
243
  const structured = hasStructuredBody(ev.full);
157
- const fullText = structured ? rawArg(ev.full) : ev.full || '';
244
+ const mdBody = open && !structured ? textBodyOf(ev.full) : null;
245
+ const blocked = structured || mdBody !== null;
246
+ const fullText = blocked ? rawArg(ev.full) : ev.full || '';
158
247
  const io = el('span', 'tr-io', (open ? fullText : ev.text) || '');
159
248
  // 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
160
249
  io.title = open ? '' : ev.text || '';
@@ -162,7 +251,7 @@ export function createTraceView(rootEl, opts = {}) {
162
251
  if (ev.full) {
163
252
  row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
164
253
  row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
165
- if (open) row.className += ' tr-open' + (structured ? ' tr-structured' : '');
254
+ if (open) row.className += ' tr-open' + (blocked ? ' tr-structured' : '');
166
255
  row.addEventListener('click', () => {
167
256
  if (!hasSelection()) toggleExpand(ev.key);
168
257
  });
@@ -171,13 +260,15 @@ export function createTraceView(rootEl, opts = {}) {
171
260
  // 结构化正文是**兄弟块**而不是 `.tr-io` 的子节点:行是 flex + nowrap,
172
261
  // 塞进去会把树的列对齐打乱;独立成块才能自己换行、自己缩进。
173
262
  // 折叠状态按事件键取一份带进去(2026-11-11 晚):块每次重建都 new,状态不能放块里。
174
- if (open && structured) {
263
+ // `body` 是宿主渲染好的正文(Markdown),交给同一条挂载路径:块头、复制、`.tr-io`
264
+ // 隐藏这些行为必须一致,否则「哪种正文」会从**外观**上分叉成两套。
265
+ if (open && blocked) {
175
266
  let fold = folds.get(ev.key);
176
267
  if (!fold) {
177
268
  fold = new Map();
178
269
  folds.set(ev.key, fold);
179
270
  }
180
- rootEl.appendChild(createFullView(ev.full, { fold }));
271
+ rootEl.appendChild(createFullView(ev.full, { fold, body: mdBody }));
181
272
  }
182
273
  return;
183
274
  }
@@ -187,6 +278,18 @@ export function createTraceView(rootEl, opts = {}) {
187
278
  row.dataset.kind = node.kind;
188
279
  const ut = node.kind === 'capability' ? capabilityTypeOf(node.name) : '';
189
280
  if (ut) row.dataset.capability = ut;
281
+ const attrKeys = node.attributes ? Object.keys(node.attributes) : [];
282
+ const attrKey = 'sp:' + node.id;
283
+ const attrOpen = attrKeys.length > 0 && expanded.has(attrKey);
284
+ /* 命中面:名字 / 入参摘要 / 错误原文 / **已展开的**属性值。
285
+ 属性只在展开时才算:折叠态那些值本来就没显示 —— 高亮一个看不见的东西只会让
286
+ 「N 处匹配」对不上眼睛看到的。 */
287
+ const attrVals = attrOpen
288
+ ? Object.values(node.attributes).map((v) => (typeof v === 'string' ? v : String(v)))
289
+ : [];
290
+ if (hits([node.name, node.arg, bad ? node.error?.message : '', ...attrVals])) {
291
+ row.className += ' tr-hit';
292
+ }
190
293
  row.appendChild(el('span', 'tr-pre', branch));
191
294
  row.appendChild(el('span', 'tr-dot', node.done ? (bad ? '✕' : '●') : '◌'));
192
295
  if (ut) row.appendChild(el('span', 'tr-ico', CAP_ICO[ut]));
@@ -208,10 +311,46 @@ export function createTraceView(rootEl, opts = {}) {
208
311
  if (bad) parts.push(node.error?.type || 'error');
209
312
  meta = parts.join(' · ');
210
313
  } else meta = '…';
314
+ /* 时间轴条(2026-11-13):按 run 的总时长归一化 —— 「8 秒被谁吃掉 / 谁和谁并行」
315
+ 只有结构视角是看不见的。放在 meta **之前**并自己吃掉 `margin-left: auto`,
316
+ 让「耗时读数 — 轨道」贴在一起(两者说的是同一件事)。根行不画:它恒占满全宽。 */
317
+ if (!isRoot && timeline && t0 !== null && total > 0 && typeof node.startedAt === 'number') {
318
+ const offset = node.startedAt - t0;
319
+ const left = Math.min(1, Math.max(0, offset / total));
320
+ const dur = node.done && node.ms > 0 ? node.ms : Math.max(total - offset, 0);
321
+ const width = Math.min(1 - left, dur / total);
322
+ const track = el('span', 'tr-tl');
323
+ const bar = el('span', 'tr-tl-bar');
324
+ // 几何只能走 style(宽度是**数据**,不是样式选择);两端都 clamp 过 ⇒ 不会溢出轨道
325
+ bar.style.cssText = `left:${(left * 100).toFixed(2)}%;width:${Math.max(width * 100, 0.6).toFixed(2)}%`;
326
+ track.appendChild(bar);
327
+ track.title = `起点 ${Math.round(offset)}ms · 时长 ${Math.round(dur)}ms`;
328
+ row.dataset.tl = '1';
329
+ row.appendChild(track);
330
+ }
211
331
  const metaEl = el('span', 'tr-meta', meta);
212
332
  if (bad && node.error?.message) metaEl.title = node.error.message;
213
333
  row.appendChild(metaEl);
334
+ /* span 属性(2026-11-13):`model` / `system.version` / `tools.names` / `retry.attempt` /
335
+ `handoff.*` / `guardrail.*` 全在 attributes 里,此前树上一格都不显示 ——
336
+ 「这次是不是提示词版本没换」「是不是重试了三次」只能靠猜。
337
+ 块体复用 `full-view.js`(纯 DOM 构造,不再写第二套渲染)。 */
338
+ if (attrKeys.length > 0) {
339
+ row.dataset.expandable = '1';
340
+ row.appendChild(el('span', 'tr-caret', attrOpen ? '▾' : '▸'));
341
+ row.addEventListener('click', () => {
342
+ if (!hasSelection()) toggleExpand(attrKey);
343
+ });
344
+ }
214
345
  rootEl.appendChild(row);
346
+ if (attrOpen) {
347
+ let fold = folds.get(attrKey);
348
+ if (!fold) {
349
+ fold = new Map();
350
+ folds.set(attrKey, fold);
351
+ }
352
+ rootEl.appendChild(createFullView(node.attributes, { fold }));
353
+ }
215
354
  // 失败原文必须**看得见** —— 只塞进 title(hover 才显)等于没显示。首次运行最常见的失败
216
355
  // (没配 API key)就靠这一行定位;单起一行,不去挤占 nowrap 的 span 行。
217
356
  if (bad && node.error?.message) {
@@ -237,10 +376,19 @@ export function createTraceView(rootEl, opts = {}) {
237
376
  usageEls.cost.textContent = '$' + cost.toFixed(4);
238
377
  }
239
378
 
240
- /** 开树。rootName 为 run 根显示名(如 'run · 审查一份文档') */
241
- function reset(rootName) {
379
+ /**
380
+ * 开树。rootName 为 run 根显示名(如 'run · 审查一份文档')。
381
+ *
382
+ * `rootId` = **框架给这个根 span 的真 `spanId`**(`fromTrace.js` 的 `playTrace` 会传)。
383
+ * 为什么必须传:根节点上的事件(`score` / `trace.truncated` / `approval.decided`)由
384
+ * `event(span.spanId, …)` 投递,而旧实现只把根登记在字面量 `'root'` 上 ⇒ `spanMap.get`
385
+ * 落空 ⇒ `event()` 静默 return。症状不是报错,是**评估结论与截断警告整类消失**,而子 span
386
+ * 的事件一切正常(排查时最容易被判成「框架没记」)。字面量 `'root'` 仍登记:官网
387
+ * playground 的模拟脚本用它当根 id,两个键指向同一节点,零成本。
388
+ */
389
+ function reset(rootName, rootId, startedAt, attributes) {
242
390
  traceRoot = {
243
- id: 'root',
391
+ id: rootId || 'root',
244
392
  kind: 'run',
245
393
  name: rootName || 'run',
246
394
  children: [],
@@ -248,10 +396,23 @@ export function createTraceView(rootEl, opts = {}) {
248
396
  order: [],
249
397
  done: false,
250
398
  ms: 0,
399
+ /* 时间轴的基准(2026-11-13):根 span 的起点。缺省 null ⇒ 整棵树不画条
400
+ (手写 trace / 官网模拟回放没有时间戳,画一条人人占满的条比不画更误导)。 */
401
+ startedAt: typeof startedAt === 'number' ? startedAt : null,
402
+ endAt: null,
403
+ /* 根 span 的属性(2026-11-13):`model` / `system.version` / `tools.names` /
404
+ `prompts.versions` / `stop_reason` **只写在根上**。根行不经过 `start()`
405
+ (它由 reset 建),所以不在这里接一手,这些键在面板上就一格都看不到 ——
406
+ 「这次是不是提示词版本没换」只能去翻 JSON。空对象当没有。 */
407
+ attributes:
408
+ attributes && typeof attributes === 'object' && Object.keys(attributes).length > 0
409
+ ? attributes
410
+ : null,
251
411
  usage: { input: 0, output: 0 },
252
412
  };
253
413
  spanMap.clear();
254
414
  spanMap.set('root', traceRoot);
415
+ if (rootId && rootId !== 'root') spanMap.set(rootId, traceRoot);
255
416
  expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
256
417
  folds.clear(); // 块内折叠同理:留着只会在下一次点开时出现"莫名已折叠"的状态
257
418
  usageAcc.input = 0;
@@ -272,6 +433,9 @@ export function createTraceView(rootEl, opts = {}) {
272
433
  order: [],
273
434
  done: false,
274
435
  ms: 0,
436
+ startedAt: typeof s.startedAt === 'number' ? s.startedAt : null,
437
+ endAt: null,
438
+ attributes: s.attributes && typeof s.attributes === 'object' ? s.attributes : null,
275
439
  status: 'ok',
276
440
  error: null,
277
441
  usage: null,
@@ -311,6 +475,14 @@ export function createTraceView(rootEl, opts = {}) {
311
475
  if (!node) return;
312
476
  node.done = true;
313
477
  node.ms = s.ms || 0;
478
+ /* 终点优先取框架的真 `endedAt`(时间轴的右端);没有就按起点 + 时长推。
479
+ 两者都没有(手写 trace)⇒ 留 null,那一段不画条。 */
480
+ node.endAt =
481
+ typeof s.endedAt === 'number'
482
+ ? s.endedAt
483
+ : typeof node.startedAt === 'number'
484
+ ? node.startedAt + node.ms
485
+ : null;
314
486
  node.status = s.status || 'ok';
315
487
  node.error = s.error || null;
316
488
  if (s.arg) node.arg = s.arg;
@@ -337,6 +509,9 @@ export function createTraceView(rootEl, opts = {}) {
337
509
  if (!traceRoot) return;
338
510
  traceRoot.done = true;
339
511
  traceRoot.ms = ms;
512
+ if (typeof traceRoot.startedAt === 'number' && typeof ms === 'number') {
513
+ traceRoot.endAt = traceRoot.startedAt + ms;
514
+ }
340
515
  // run 失败时根 span 也要标红:原先 status 恒为 ok,错误只体现在子 span 上
341
516
  traceRoot.status = status || 'ok';
342
517
  traceRoot.error = error || null;
@@ -344,14 +519,76 @@ export function createTraceView(rootEl, opts = {}) {
344
519
  renderTrace();
345
520
  }
346
521
 
522
+ /** 当前命中行(`.tr-hit`)。宿主没有 `querySelectorAll`(stub / 极简 DOM)时返回空集 */
523
+ function hitRows() {
524
+ try {
525
+ if (typeof rootEl.querySelectorAll !== 'function') return [];
526
+ return Array.from(rootEl.querySelectorAll('.tr-hit'));
527
+ } catch {
528
+ return [];
529
+ }
530
+ }
531
+
532
+ /** 加/去一个类名(不依赖 classList:stub DOM 未必实现它) */
533
+ function setFlag(node, cls, on) {
534
+ const parts = String(node.className || '')
535
+ .split(/\s+/)
536
+ .filter((c) => c && c !== cls);
537
+ if (on) parts.push(cls);
538
+ node.className = parts.join(' ');
539
+ }
540
+
347
541
  return {
348
542
  reset,
349
543
  start,
350
544
  event,
351
545
  end,
352
546
  finish,
547
+ /** 把一批动作压成一次重画(`playTrace` 必用;宿主自己批量喂时也可以用) */
548
+ batch,
353
549
  render: renderTrace,
550
+ /**
551
+ * 树内检索:**只高亮 + 跳转,不改结构**(理由见 `query` 的头注)。返回命中行数。
552
+ * 空串 = 结束检索(清掉所有高亮)。
553
+ */
554
+ setQuery(q) {
555
+ const next = typeof q === 'string' ? q.trim() : '';
556
+ if (next === query) return hitRows().length;
557
+ query = next;
558
+ renderTrace();
559
+ return hitRows().length;
560
+ },
561
+ get hitCount() {
562
+ return hitRows().length;
563
+ },
564
+ /**
565
+ * 跳到下一处命中(`dir < 0` 向上),返回 **1 基序号**;没有命中返回 0。
566
+ * 越界绕回:搜索框里按 Enter 一路按到底再回到第一处,比「按不动了」更符合预期。
567
+ */
568
+ nextHit(dir = 1) {
569
+ const list = hitRows();
570
+ if (list.length === 0) return 0;
571
+ hitIndex = (hitIndex + (dir > 0 ? 1 : -1) + list.length) % list.length;
572
+ const cur = list[hitIndex];
573
+ for (const r of list) setFlag(r, 'tr-hit-cur', r === cur);
574
+ try {
575
+ cur.scrollIntoView?.({ block: 'center' });
576
+ } catch {
577
+ /* 没有滚动能力(stub / 老浏览器):序号仍然对,只是没把行带进视野 */
578
+ }
579
+ return hitIndex + 1;
580
+ },
354
581
  setUsage: renderUsage,
582
+ /** 时间轴开关(默认开)。关掉只是不画轨道,数据与命中都不受影响 */
583
+ setTimeline(on) {
584
+ const next = on !== false;
585
+ if (next === timeline) return;
586
+ timeline = next;
587
+ renderTrace();
588
+ },
589
+ get timeline() {
590
+ return timeline;
591
+ },
355
592
  /** 换单价(如真实模式切服务商):null 表示无公开单价 → 成本显示为 — */
356
593
  setPrice(p) {
357
594
  price = p ?? null;