@migor/cli 0.10.6 → 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,52 @@
5
5
  (0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
6
6
  决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
7
7
 
8
+ ## [0.10.7] - 2026-11-12
9
+
10
+ ### 变更
11
+
12
+ 本版只改**调试面板的展开呈现**,无破坏性变更、无迁移动作。两包同步 0.10.6 → 0.10.7;
13
+ `@migor/agentia` 此次只有版本常量变化(`src/` 一行未动),实际改动全在调试面板与
14
+ `agentia view` 的产物面。
15
+
16
+ - **展开态不再是一大坨 JSON**:`tool.input` / `tool.output` 是对象或数组时,展开行下方追加
17
+ 一个**结构化正文块** —— 缩进、四类值着色、头部写类型与规模(`object · 6 项`)。
18
+ 此前唯一的展开方式是把整串 JSON 折行,20 个键在 400px 宽的右栏里就是十几行连续的
19
+ `{"a":"b","c":{"d":`:页面不报错、无溢出、构建与 e2e 全绿,只是人读不了 ——
20
+ 而「展开看工具到底收到/返回了什么」正是这块面板的主用途。
21
+ - **逐层折叠**:容器默认只铺两层,更深的折成 `▸ watch: { object · 2 项 }`,点一下铺开。
22
+ 折叠状态由调用方持有,所以**实时 run 里刚折起的容器不会被下一条事件弹开**。
23
+ - **同构数组成表**:元素键集完全相等、且各列都是标量时铺成表(`targets[*].file` 这种表头),
24
+ 3 行 `{name, score}` 从 9 行重复键名压到 1 行表头 + 3 行数据。判据刻意从严 ——
25
+ 表格里没法逐层展开,判错一次就是**静默丢列**,所以任一条件不满足就回落树形。
26
+ - **键路径可复制**:悬停键名出 `⧉`,点一下写入 `results[0].name`。没有剪贴板实现时
27
+ 点击不生效、也不显示「已复制」—— 不把没做到的事显示成做到了。
28
+ - **三处宿主同时生效**:CLI 的 `agentia dev` 面板、`agentia view` 生成的单文件 HTML、
29
+ 官网 Playground 共用这份渲染器。
30
+ - 字符串形态的展开(`tool.output` 的文本正文、官网模拟回放)**照旧**走原来的纯文本换行 ——
31
+ 那一档有两个 CSS 不变量守着,刻意保留两档并存,不做统一。
32
+
33
+ ### 新增:展开态结构化渲染(2026-11-11 ref-trace-full-view)
34
+
35
+ - **新件 `packages/trace-view/src/full-view.js`**:入参任意值、出参一个元素,与树的构建与
36
+ 状态机无关(零依赖、纯 DOM 构造、无内部状态)。默认铺两层(`OPEN_DEPTH = 2`)。
37
+ - **数据侧递原值**:`fromTrace.eventFull` 对 `tool.input` / `tool.output` 返回**原值**而非
38
+ `JSON.stringify` 结果 —— 此前在数据层就序列化了,渲染器根本看不到对象,结构化无从发生。
39
+ `rawArg` 保留(`.tr-io` 的正文与既有展开断言都靠它)。
40
+ - **原文仍然在场**:结构化时给行打 `tr-structured`,CSS 只是隐藏 `.tr-io`,它依旧持有
41
+ `JSON.stringify` 原文 ⇒ 既有 9 条展开断言一条都不用改,用户选中复制也照旧。
42
+ 改的是**画法**,不是数据。
43
+ - **截断必须说出来**:单值超 240 字符写「还有 N 字符」,一层超 40 项写「还有 N 项未展开」,
44
+ 嵌套超 6 层压平但仍标出「它是个容器」。静默截断比糊成一坨更坏 —— 它会让人以为工具
45
+ 就返回了这么点东西。循环引用不抛(调试面板不该因一条坏数据白屏)。
46
+ - **CLI 侧**:`inspector-routes.ts` 的 STATIC 白名单加 `full-view.js`(漏登记的后果是浏览器
47
+ 404、面板整块求值失败),`packages/cli/test/structure.test.mjs` 的 W5 守着这条。
48
+ - **验证**:trace-view 测试 +19(渲染件 15、接线 4),跑真 `createTraceView` + `playTrace`;
49
+ 反向验证 5 条(不传 `fold` ⇒ 折叠跨重建用例红;摘掉「键数相等」判定 ⇒ 成表边界红;
50
+ 摘掉挂块那一行 ⇒ 两处接线红;`eventFull` 退回 `JSON.stringify` ⇒ 结构化用例红;
51
+ `copyInto` 写 `title` 而非路径 ⇒ 复制用例红)。决策与判据见 `docs/spec.md` §10
52
+ 2026-11-11 ref-trace-full-view,守卫登记见 `docs/guards.md` §1。
53
+
8
54
  ## [0.10.6] - 2026-11-09
9
55
 
10
56
  ### 变更
@@ -4426,7 +4472,8 @@ toolkit 142 / runtime 55 / engine 403 / container 10 / store 55 —— 全绿。
4426
4472
  首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
4427
4473
  框架本体单包;CLI 独立成包(workspaces)。
4428
4474
 
4429
- [Unreleased]: https://github.com/migoric/agentia/compare/v0.10.6...HEAD
4475
+ [Unreleased]: https://github.com/migoric/agentia/compare/v0.10.7...HEAD
4476
+ [0.10.7]: https://github.com/migoric/agentia/releases/tag/v0.10.7
4430
4477
  [0.10.6]: https://github.com/migoric/agentia/releases/tag/v0.10.6
4431
4478
  [0.10.5]: https://github.com/migoric/agentia/releases/tag/v0.10.5
4432
4479
  [0.10.4]: https://github.com/migoric/agentia/releases/tag/v0.10.4
@@ -14,7 +14,7 @@
14
14
  * 3. usage 只由 llm.turn 累计(capability span 的 usage 是子 span 聚合,计入会双算)。
15
15
  */
16
16
 
17
- import { fmtArg, rawArg, capabilityTypeOf, CAP_ICO } from './view.js';
17
+ import { fmtArg, capabilityTypeOf, CAP_ICO } from './view.js';
18
18
 
19
19
  /** 从 span.attributes 认能力类型(框架用 `setAttribute(capabilityId, 'subagent', name)` 记类型) */
20
20
  function spanType(s) {
@@ -68,15 +68,24 @@ function eventText(ev) {
68
68
  }
69
69
 
70
70
  /**
71
- * 事件全文(展开态用)。
71
+ * 事件全文(展开态用)—— **给原值,不预先序列化**。
72
72
  *
73
- * 只有 `tool.input` 需要单独取:`fmtArg` 把入参摘要**砍到 4 个键 / 每值 21 字符 / 整串 62 字符**,
74
- * 直接拿摘要当全文就是「假展开」—— 点开看到的还是那 62 个字符。出参与其他事件本身就是原文
75
- * (`eventText` 原样返回 content),无需二次处理。
76
- * 原文口径统一在 view.js 的 `rawArg`:官网 playground 的模拟回放也要用它,两处一处定义。
73
+ * 2026-11-11 起 `full` 的类型本身携带语义:字符串 ⇒ 渲染器走 `.tr-io` 的纯文本换行;
74
+ * 对象/数组 ⇒ 交给 `full-view.js` 铺成带缩进的结构化块。所以这里必须把**原值**递下去 ——
75
+ * 若仍在这里 `rawArg` 成字符串,「一大坨 JSON」那条路就永远回不来了(渲染器只能看到字符串)。
76
+ *
77
+ * 口径:
78
+ * - `tool.input` → 入参原值(`fmtArg` 的摘要砍到 4 键 / 每值 21 字符 / 整串 62 字符,
79
+ * 拿摘要当全文就是「假展开」);
80
+ * - `tool.output` → `content` 原值:字符串就是纯文本(含图片块时会是非字符串,正好结构化);
81
+ * - 其余事件 → `eventText` 的字符串(它们的体本来就是给人看的一小段说明)。
77
82
  */
78
83
  function eventFull(ev) {
79
- if (ev.name === 'tool.input') return rawArg(ev.body?.input);
84
+ if (ev.name === 'tool.input') return ev.body?.input ?? '';
85
+ if (ev.name === 'tool.output') {
86
+ const c = ev.body?.content;
87
+ return c == null ? '' : c;
88
+ }
80
89
  return eventText(ev);
81
90
  }
82
91
 
@@ -0,0 +1,369 @@
1
+ /* 展开态的**结构化正文** —— 把「一大坨 JSON」拆成可折叠、可成表、可复制路径的块。
2
+ *
3
+ * ## 为什么需要它
4
+ *
5
+ * 展开此前只有一条路:把 `rawArg(v)`(= `JSON.stringify(v)`,**无缩进**)整串塞进
6
+ * `.tr-io` 再靠 CSS 换行。一个 20 键的工具入参在 400px 宽的右栏里会折成十几行连续的
7
+ * `{"a":"b","c":{"d":` —— 页面不报错、无溢出、构建全绿,只是**人读不了**。工具出参更糟:
8
+ * 它是 `content` 的原始序列化,长文本与图片块混在一起,一行到底。
9
+ *
10
+ * 缩进只是第一档。第二档(2026-11-11 晚,本文件的现状)解决缩进解决不了的三件事:
11
+ *
12
+ * 1. **静态文本没有"收起"**:深度 6 全铺开,一个出参就能把整棵树顶到屏幕外。现在容器
13
+ * 默认只铺两层(`OPEN_DEPTH`),其余折成 `{ object · 4 项 }`,点一下展开。折叠状态由
14
+ * 调用方传入(`opts.fold`)—— 渲染是全量重建,状态放本文件里活不过下一条事件。
15
+ * 2. **同构对象数组在浪费行**:`[{name,score},{...},{...}]` 每个元素重复一遍键名,3 行数据
16
+ * 吃掉 9 行。同构且各列都是标量时改成表格,表头一行、数据一行。
17
+ * 3. **路径拿不到**:想引用 `results[0].name` 只能用手数层级。每个键悬停出一个 `⧉`,
18
+ * 点一下把路径写进剪贴板(表头写的是 `results[*].name`,因为它是一整列)。
19
+ *
20
+ * ## 边界(都是「不这么做就会坏」的地方,不是口味)
21
+ *
22
+ * - **只在 `full` 不是字符串时接手**:字符串正文(工具返回的纯文本、官网模拟回放)
23
+ * 继续走 `.tr-io` + `.tr-open` 的老路 —— 那条路有 `test/style.test.js` 的两个不变量守着
24
+ * (最小宽度、caret 常显),换成本件就会让那两个守卫失去落点。两档并存是刻意的。
25
+ * - **不改 `.tr-io` 的文本**:本件只是**追加**一个兄弟块,`.tr-io` 仍持有原文(展开时由
26
+ * CSS 隐藏)。既有断言(`textContent === JSON.stringify(...)`)因此逐字保留 ——
27
+ * 展开的正文依旧是"原文",只是多了一层可读的呈现。
28
+ * - **截断必须说出来**:长字符串与超大集合会截断,但截断处标明「还有 N 字符 / N 项」。
29
+ * 静默截断会让人以为工具就返回了这么点东西 —— 比糊成一坨更坏。表格单元格同样走
30
+ * `primitive()`,所以这条在表里也成立(不做「表格例外」)。
31
+ * - **循环引用不抛**:手写的 trace 可能有环(`rawArg` 对它会回落 `[object Object]`),
32
+ * 这里同样只回落、不抛错:调试面板不该因为一条坏数据整棵树白屏。
33
+ * - **折叠状态必须外部持有**:`opts.fold` 是调用方的 Map(`view.js` 按事件键存一份)。
34
+ * 本文件只读写它、不 new 一份长期状态 —— 否则「点开一个容器,下一条事件到达时它自己合上」
35
+ * 会原样复现(这正是 `view.js` 的 `expanded` 集合存在的原因)。
36
+ * - **表格化只在"真的同构"时发生**:元素必须全是普通对象、键集完全一致、列数 1..6、
37
+ * 每列都是标量。任一不满足就回落树形 —— 表格里没法逐层展开,判错一次就是**丢数据**。
38
+ *
39
+ * 零依赖、纯 DOM 构造(只用 createElement / appendChild / textContent / className /
40
+ * dataset / innerHTML=''),与 view.js 共用同一套 DOM stub 跑测试。
41
+ */
42
+
43
+ /** 单个字符串值最多显示多少字符(超过就说出来,不静默截断) */
44
+ const MAX_STR = 240;
45
+ /** 一层里最多铺开多少项(再多就没法看了,留着类型与计数) */
46
+ const MAX_ITEMS = 40;
47
+ /** 嵌套深度上限:再深就整体序列化成一行,避免缩进把宽度吃光 */
48
+ const MAX_DEPTH = 6;
49
+ /** 默认展开到第几层(容器自身深度 < 它 ⇒ 默认铺开)。两层 = 根 + 一层子节点 */
50
+ const OPEN_DEPTH = 2;
51
+ /** 表格最多几列:再多横向就没法看了,回落树形 */
52
+ const MAX_COLS = 6;
53
+
54
+ function fvEl(tag, cls, text) {
55
+ const node = document.createElement(tag);
56
+ if (cls) node.className = cls;
57
+ if (text !== undefined) node.textContent = text;
58
+ return node;
59
+ }
60
+
61
+ /** 安全序列化:循环引用 / BigInt / 自定义 toJSON 抛错都回落成一句人话,绝不冒泡 */
62
+ function safeStringify(v) {
63
+ try {
64
+ const s = JSON.stringify(v, null, 2);
65
+ return s === undefined ? String(v) : s;
66
+ } catch {
67
+ return '[无法序列化 —— 可能是循环引用]';
68
+ }
69
+ }
70
+
71
+ /** 该值是否值得走结构化视图(字符串交给 .tr-io 的老路) */
72
+ function isStructured(v) {
73
+ return v !== null && typeof v === 'object';
74
+ }
75
+
76
+ /** 普通对象(排除数组与 null)—— 表格化与"容器"判定都只要这一种 */
77
+ function isPlainObject(v) {
78
+ return isStructured(v) && !Array.isArray(v);
79
+ }
80
+
81
+ /** 容器规模:数组算元素数,对象算键数 */
82
+ function sizeOf(v) {
83
+ return Array.isArray(v) ? v.length : Object.keys(v).length;
84
+ }
85
+
86
+ /** 头部那句类型说明:`object · 6 项` / `array · 12 项` / `string · 4,096 字符` */
87
+ function typeLabelOf(v) {
88
+ if (Array.isArray(v)) return `array · ${v.length} 项`;
89
+ if (isStructured(v)) return `object · ${Object.keys(v).length} 项`;
90
+ if (typeof v === 'string') return `string · ${v.length} 字符`;
91
+ return typeof v;
92
+ }
93
+
94
+ /** 基本类型的着色类与展示文本 */
95
+ function primitive(v) {
96
+ if (v === null) return { cls: 'tr-full-null', text: 'null' };
97
+ if (v === undefined) return { cls: 'tr-full-null', text: 'undefined' };
98
+ const t = typeof v;
99
+ if (t === 'string') {
100
+ const clipped = v.length > MAX_STR ? v.slice(0, MAX_STR) : v;
101
+ const rest = v.length > MAX_STR ? `… 还有 ${v.length - MAX_STR} 字符` : '';
102
+ return { cls: 'tr-full-str', text: JSON.stringify(clipped) + rest };
103
+ }
104
+ if (t === 'number') return { cls: 'tr-full-num', text: String(v) };
105
+ if (t === 'boolean') return { cls: 'tr-full-bool', text: String(v) };
106
+ return { cls: 'tr-full-null', text: String(v) };
107
+ }
108
+
109
+ /** 深层容器压成单行时的表示:保留"它是个容器"这个事实,不伪装成标量 */
110
+ function oneLine(v) {
111
+ const kind = Array.isArray(v) ? 'array' : 'object';
112
+ const s = safeStringify(v).replace(/\s+/g, ' ');
113
+ const clipped = s.length > MAX_STR ? s.slice(0, MAX_STR) + '…' : s;
114
+ return `<${kind} · ${sizeOf(v)} 项> ${clipped}`;
115
+ }
116
+
117
+ /** 子键的 JSON 路径:对象用 `.key`,数组用 `[i]`。根是空串,所以 `[0]` 不会变成 `.[0]` */
118
+ function pathOf(parent, key, isArr) {
119
+ return isArr ? `${parent}[${key}]` : parent ? `${parent}.${key}` : key;
120
+ }
121
+
122
+ /** 剪贴板:没有实现时**什么都不做**,绝不把没做到的事显示成「已复制」 */
123
+ function copyInto(btn, text, done) {
124
+ const clip = globalThis.navigator?.clipboard;
125
+ if (!clip || typeof clip.writeText !== 'function') return;
126
+ Promise.resolve(clip.writeText(text)).then(
127
+ () => {
128
+ btn.textContent = done;
129
+ },
130
+ () => {
131
+ btn.textContent = '复制失败';
132
+ },
133
+ );
134
+ }
135
+
136
+ /** 正在选文本时不要收起/展开 —— 鼠标拖选到行外松手会补一次 click,否则选完就自己合上了 */
137
+ function hasSelection() {
138
+ try {
139
+ return String(globalThis.getSelection?.() ?? '') !== '';
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /** 悬停才露出的 `⧉`:点它复制路径,**不**触发所在容器的折叠 */
146
+ function pathBtn(path) {
147
+ const btn = fvEl('span', 'tr-full-path', '⧉');
148
+ btn.title = '复制路径 ' + path;
149
+ btn.addEventListener('click', (e) => {
150
+ // 真实 DOM 里会冒泡到容器行的折叠处理;stub 的 click() 传的是空对象,故双保险
151
+ if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
152
+ copyInto(btn, path, '已复制');
153
+ });
154
+ return btn;
155
+ }
156
+
157
+ /**
158
+ * 这一层要不要铺开。显式折叠/展开优先(`fold` 里有记录),否则按深度给默认值。
159
+ * @param {Map<string, boolean>} fold 路径 → 是否展开
160
+ */
161
+ function isOpen(fold, path, depth) {
162
+ return fold.has(path) ? fold.get(path) === true : depth < OPEN_DEPTH;
163
+ }
164
+
165
+ /**
166
+ * 同构对象数组 → 列名;不是"真同构"就返回 null(调用方回落树形)。
167
+ *
168
+ * 判据全部是「表格会丢东西」的地方,松一条就丢一类数据:元素必须是普通对象、
169
+ * 键集**完全一致**(缺键/多键都回落)、列数 1..6、每列都是标量(表格里没法逐层展开)。
170
+ */
171
+ function tableColsOf(arr) {
172
+ if (!Array.isArray(arr) || arr.length < 2 || arr.length > MAX_ITEMS) return null;
173
+ if (!isPlainObject(arr[0])) return null;
174
+ const cols = Object.keys(arr[0]);
175
+ if (cols.length === 0 || cols.length > MAX_COLS) return null;
176
+ for (const item of arr) {
177
+ if (!isPlainObject(item)) return null;
178
+ if (Object.keys(item).length !== cols.length) return null;
179
+ for (const c of cols) {
180
+ if (!Object.hasOwn(item, c)) return null;
181
+ if (isStructured(item[c])) return null;
182
+ }
183
+ }
184
+ return cols;
185
+ }
186
+
187
+ /**
188
+ * 同构数组铺成表格。缩进用 `data-depth`(值为 0 时不写)—— CSS 靠属性选择器补左外边距,
189
+ * 因为列数是动态的、行数也是动态的,没有一个能写死的 inline style 可靠活在 stub 里。
190
+ */
191
+ function renderTable(ctx, arr, cols, depth, path) {
192
+ const table = fvEl('table', 'tr-full-table');
193
+ if (depth > 0) table.dataset.depth = String(depth);
194
+
195
+ const thead = fvEl('thead');
196
+ const headRow = fvEl('tr');
197
+ for (const c of cols) {
198
+ const th = fvEl('th');
199
+ th.appendChild(fvEl('span', 'tr-full-col', c));
200
+ // 列的路径带 `[*]`:复制出去能直接用于过滤/取值,而不是某一行的那一份
201
+ th.appendChild(pathBtn(pathOf(path + '[*]', c, false)));
202
+ headRow.appendChild(th);
203
+ }
204
+ thead.appendChild(headRow);
205
+ table.appendChild(thead);
206
+
207
+ const tbody = fvEl('tbody');
208
+ for (const item of arr) {
209
+ const tr = fvEl('tr');
210
+ for (const c of cols) {
211
+ const td = fvEl('td');
212
+ const p = primitive(item[c]); // 表里同样截断并说出剩余(不做「表格例外」)
213
+ td.appendChild(fvEl('span', p.cls, p.text));
214
+ tr.appendChild(td);
215
+ }
216
+ tbody.appendChild(tr);
217
+ }
218
+ table.appendChild(tbody);
219
+ ctx.body.appendChild(table);
220
+ }
221
+
222
+ /** 缩进空格的落点:0 层不占位(否则每条顶级键都白带两个空格) */
223
+ function indentOf(depth) {
224
+ return depth > 0 ? fvEl('span', 'tr-full-indent', ' '.repeat(depth)) : null;
225
+ }
226
+
227
+ /** 折叠箭头的槽位。叶子行也占一格(传空串),否则同层键名会在「有箭头」与「没箭头」之间
228
+ * 左右跳 10px —— 一列键名对齐是这类面板最基本的可读性,不能靠行首参差来区分有没有子项。 */
229
+ function caretSlot(glyph) {
230
+ return fvEl('span', 'tr-full-caret', glyph);
231
+ }
232
+
233
+ /** 一行 = 缩进 + 箭头槽位(+ 稍后追加的键名/值)。所有行都从同一个入口起头,
234
+ * 这样才能保证「同一层的键名在同一条竖线上」—— 逐处手写就迟早漏一处。 */
235
+ function startRow(depth, caret) {
236
+ const row = fvEl('div', 'tr-full-line');
237
+ const indent = indentOf(depth);
238
+ if (indent) row.appendChild(indent);
239
+ row.appendChild(caretSlot(caret));
240
+ return row;
241
+ }
242
+
243
+ /**
244
+ * 把一个值铺进 `ctx.body`。
245
+ * @param {{body: Element, fold: Map<string, boolean>, repaint: () => void}} ctx
246
+ * @param {any} v 值
247
+ * @param {number} depth 当前缩进层级(根为 0)
248
+ * @param {string} path 当前值的 JSON 路径(根为空串)
249
+ */
250
+ function renderInto(ctx, v, depth, path) {
251
+ const box = ctx.body;
252
+
253
+ if (!isStructured(v)) {
254
+ const p = primitive(v);
255
+ const row = startRow(depth, '');
256
+ row.appendChild(fvEl('span', p.cls, p.text));
257
+ box.appendChild(row);
258
+ return;
259
+ }
260
+
261
+ const isArr = Array.isArray(v);
262
+ const cols = isArr ? tableColsOf(v) : null;
263
+ if (cols) {
264
+ renderTable(ctx, v, cols, depth, path);
265
+ return;
266
+ }
267
+
268
+ const keys = isArr ? v.map((_, i) => String(i)) : Object.keys(v);
269
+ const show = keys.slice(0, MAX_ITEMS);
270
+
271
+ for (const k of show) {
272
+ const child = isArr ? v[Number(k)] : v[k];
273
+ const keyText = isArr ? `[${k}]` : `${k}:`;
274
+ const childPath = pathOf(path, k, isArr);
275
+ // 「能否折叠」只看三件事:是容器、非空、没到深度上限 —— 与下面几个分支同一套判据。
276
+ // 先算出来是为了让箭头槽位在起行时就有正确字形(叶子行留空格位,键名才会对齐)。
277
+ const foldable = isStructured(child) && sizeOf(child) > 0 && depth < MAX_DEPTH;
278
+ const open = foldable && isOpen(ctx.fold, childPath, depth);
279
+ const row = startRow(depth, foldable ? (open ? '▾' : '▸') : '');
280
+
281
+ // 叶子:同一行里写完(缩进 + 键名 + 路径 + 值)
282
+ if (!isStructured(child)) {
283
+ const p = primitive(child);
284
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
285
+ row.appendChild(pathBtn(childPath));
286
+ row.appendChild(fvEl('span', p.cls, ' ' + p.text));
287
+ box.appendChild(row);
288
+ continue;
289
+ }
290
+
291
+ // 深度用尽 ⇒ 整体压成一行(并标明它是个容器),避免缩进吃掉全部宽度
292
+ if (depth >= MAX_DEPTH) {
293
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
294
+ row.appendChild(fvEl('span', 'tr-full-null', ' ' + oneLine(child)));
295
+ box.appendChild(row);
296
+ continue;
297
+ }
298
+
299
+ // 空容器:留一对括号,别让它看起来像"展开失败"
300
+ if (sizeOf(child) === 0) {
301
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
302
+ row.appendChild(fvEl('span', 'tr-full-null', Array.isArray(child) ? ' []' : ' {}'));
303
+ box.appendChild(row);
304
+ continue;
305
+ }
306
+
307
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
308
+ row.appendChild(pathBtn(childPath));
309
+ if (!open) {
310
+ const kind = Array.isArray(child) ? 'array' : 'object';
311
+ row.appendChild(fvEl('span', 'tr-full-hint', ` { ${kind} · ${sizeOf(child)} 项 }`));
312
+ } else if (Array.isArray(child) && tableColsOf(child)) {
313
+ // 成表时行内不铺子项,只在键名后标一句规模 —— 否则 `▾ targets:` 后面直接跟着表格,
314
+ // 看不出这是个数组、也看不出表格是不是全部内容(是不是被截断了)
315
+ row.appendChild(fvEl('span', 'tr-full-hint', ` array · ${child.length} 项`));
316
+ }
317
+ row.dataset.fold = childPath;
318
+ row.addEventListener('click', (e) => {
319
+ if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
320
+ if (hasSelection()) return;
321
+ ctx.fold.set(childPath, !open);
322
+ ctx.repaint();
323
+ });
324
+ box.appendChild(row);
325
+
326
+ if (open) renderInto(ctx, child, depth + 1, childPath);
327
+ }
328
+
329
+ if (keys.length > show.length) {
330
+ const row = startRow(depth, '');
331
+ row.appendChild(fvEl('span', 'tr-full-null', `… 还有 ${keys.length - show.length} 项未展开`));
332
+ box.appendChild(row);
333
+ }
334
+ }
335
+
336
+ /**
337
+ * 建一个结构化正文块。
338
+ * @param {any} value `ev.full` 的**原值**(不是序列化后的字符串)
339
+ * @param {{fold?: Map<string, boolean>}} [opts]
340
+ * `fold` 是**调用方持有**的折叠状态(路径 → 是否展开)。省略时本块自建一份,
341
+ * 此时折叠状态活不过下一次「全量重建」—— 只对一次性渲染(测试、静态快照)够用。
342
+ * @returns {Element} `.tr-full` 块;调用方负责 append 到展开行之后
343
+ */
344
+ export function createFullView(value, opts = {}) {
345
+ const fold = opts.fold instanceof Map ? opts.fold : new Map();
346
+
347
+ const box = fvEl('div', 'tr-full');
348
+ box.dataset.kind = Array.isArray(value) ? 'array' : 'object';
349
+
350
+ const head = fvEl('div', 'tr-full-head');
351
+ head.appendChild(fvEl('span', 'tr-full-type', typeLabelOf(value)));
352
+ const copy = fvEl('span', 'tr-full-copy', '复制');
353
+ copy.dataset.copy = '1';
354
+ copy.addEventListener('click', () => copyInto(copy, safeStringify(value), '已复制'));
355
+ head.appendChild(copy);
356
+ box.appendChild(head);
357
+
358
+ const body = fvEl('div', 'tr-full-body');
359
+ const ctx = { body, fold, repaint: null };
360
+ // 只重画正文(不动头部):折叠不需要重建整棵树,滚动位置与头部反馈都留着
361
+ ctx.repaint = () => {
362
+ body.innerHTML = '';
363
+ renderInto(ctx, value, 0, '');
364
+ };
365
+ ctx.repaint();
366
+
367
+ box.appendChild(body);
368
+ return box;
369
+ }
@@ -12,3 +12,8 @@ export {
12
12
  } from './view.js';
13
13
  export { playTrace } from './fromTrace.js';
14
14
  export { summarizeTrace, renderSummary } from './summary.js';
15
+ /* 结构化正文块(2026-11-11):`full` 是对象/数组时,渲染器会给展开行挂上这个块。
16
+ * 单独导出是因为它是**纯函数式的 DOM 构造**(入参 → 元素),宿主可以拿它渲染任意值 ——
17
+ * 而 bundle 的导出面必须与这里逐字一致(`test/bundle.test.js`),所以内部件不进公共面、
18
+ * 这一件必须进。 */
19
+ export { createFullView } from './full-view.js';
@@ -2,6 +2,377 @@
2
2
  * 用途:`agentia view` 出的单文件自包含 HTML 内联本文件(无外链 / 离线可开 / 可转发)。
3
3
  * 真源仍是 src/;改渲染逻辑请改那里再跑 build。零依赖、纯 ESM。 */
4
4
 
5
+ /* ===== src/full-view.js ===== */
6
+ /* 展开态的**结构化正文** —— 把「一大坨 JSON」拆成可折叠、可成表、可复制路径的块。
7
+ *
8
+ * ## 为什么需要它
9
+ *
10
+ * 展开此前只有一条路:把 `rawArg(v)`(= `JSON.stringify(v)`,**无缩进**)整串塞进
11
+ * `.tr-io` 再靠 CSS 换行。一个 20 键的工具入参在 400px 宽的右栏里会折成十几行连续的
12
+ * `{"a":"b","c":{"d":` —— 页面不报错、无溢出、构建全绿,只是**人读不了**。工具出参更糟:
13
+ * 它是 `content` 的原始序列化,长文本与图片块混在一起,一行到底。
14
+ *
15
+ * 缩进只是第一档。第二档(2026-11-11 晚,本文件的现状)解决缩进解决不了的三件事:
16
+ *
17
+ * 1. **静态文本没有"收起"**:深度 6 全铺开,一个出参就能把整棵树顶到屏幕外。现在容器
18
+ * 默认只铺两层(`OPEN_DEPTH`),其余折成 `{ object · 4 项 }`,点一下展开。折叠状态由
19
+ * 调用方传入(`opts.fold`)—— 渲染是全量重建,状态放本文件里活不过下一条事件。
20
+ * 2. **同构对象数组在浪费行**:`[{name,score},{...},{...}]` 每个元素重复一遍键名,3 行数据
21
+ * 吃掉 9 行。同构且各列都是标量时改成表格,表头一行、数据一行。
22
+ * 3. **路径拿不到**:想引用 `results[0].name` 只能用手数层级。每个键悬停出一个 `⧉`,
23
+ * 点一下把路径写进剪贴板(表头写的是 `results[*].name`,因为它是一整列)。
24
+ *
25
+ * ## 边界(都是「不这么做就会坏」的地方,不是口味)
26
+ *
27
+ * - **只在 `full` 不是字符串时接手**:字符串正文(工具返回的纯文本、官网模拟回放)
28
+ * 继续走 `.tr-io` + `.tr-open` 的老路 —— 那条路有 `test/style.test.js` 的两个不变量守着
29
+ * (最小宽度、caret 常显),换成本件就会让那两个守卫失去落点。两档并存是刻意的。
30
+ * - **不改 `.tr-io` 的文本**:本件只是**追加**一个兄弟块,`.tr-io` 仍持有原文(展开时由
31
+ * CSS 隐藏)。既有断言(`textContent === JSON.stringify(...)`)因此逐字保留 ——
32
+ * 展开的正文依旧是"原文",只是多了一层可读的呈现。
33
+ * - **截断必须说出来**:长字符串与超大集合会截断,但截断处标明「还有 N 字符 / N 项」。
34
+ * 静默截断会让人以为工具就返回了这么点东西 —— 比糊成一坨更坏。表格单元格同样走
35
+ * `primitive()`,所以这条在表里也成立(不做「表格例外」)。
36
+ * - **循环引用不抛**:手写的 trace 可能有环(`rawArg` 对它会回落 `[object Object]`),
37
+ * 这里同样只回落、不抛错:调试面板不该因为一条坏数据整棵树白屏。
38
+ * - **折叠状态必须外部持有**:`opts.fold` 是调用方的 Map(`view.js` 按事件键存一份)。
39
+ * 本文件只读写它、不 new 一份长期状态 —— 否则「点开一个容器,下一条事件到达时它自己合上」
40
+ * 会原样复现(这正是 `view.js` 的 `expanded` 集合存在的原因)。
41
+ * - **表格化只在"真的同构"时发生**:元素必须全是普通对象、键集完全一致、列数 1..6、
42
+ * 每列都是标量。任一不满足就回落树形 —— 表格里没法逐层展开,判错一次就是**丢数据**。
43
+ *
44
+ * 零依赖、纯 DOM 构造(只用 createElement / appendChild / textContent / className /
45
+ * dataset / innerHTML=''),与 view.js 共用同一套 DOM stub 跑测试。
46
+ */
47
+
48
+ /** 单个字符串值最多显示多少字符(超过就说出来,不静默截断) */
49
+ const MAX_STR = 240;
50
+ /** 一层里最多铺开多少项(再多就没法看了,留着类型与计数) */
51
+ const MAX_ITEMS = 40;
52
+ /** 嵌套深度上限:再深就整体序列化成一行,避免缩进把宽度吃光 */
53
+ const MAX_DEPTH = 6;
54
+ /** 默认展开到第几层(容器自身深度 < 它 ⇒ 默认铺开)。两层 = 根 + 一层子节点 */
55
+ const OPEN_DEPTH = 2;
56
+ /** 表格最多几列:再多横向就没法看了,回落树形 */
57
+ const MAX_COLS = 6;
58
+
59
+ function fvEl(tag, cls, text) {
60
+ const node = document.createElement(tag);
61
+ if (cls) node.className = cls;
62
+ if (text !== undefined) node.textContent = text;
63
+ return node;
64
+ }
65
+
66
+ /** 安全序列化:循环引用 / BigInt / 自定义 toJSON 抛错都回落成一句人话,绝不冒泡 */
67
+ function safeStringify(v) {
68
+ try {
69
+ const s = JSON.stringify(v, null, 2);
70
+ return s === undefined ? String(v) : s;
71
+ } catch {
72
+ return '[无法序列化 —— 可能是循环引用]';
73
+ }
74
+ }
75
+
76
+ /** 该值是否值得走结构化视图(字符串交给 .tr-io 的老路) */
77
+ function isStructured(v) {
78
+ return v !== null && typeof v === 'object';
79
+ }
80
+
81
+ /** 普通对象(排除数组与 null)—— 表格化与"容器"判定都只要这一种 */
82
+ function isPlainObject(v) {
83
+ return isStructured(v) && !Array.isArray(v);
84
+ }
85
+
86
+ /** 容器规模:数组算元素数,对象算键数 */
87
+ function sizeOf(v) {
88
+ return Array.isArray(v) ? v.length : Object.keys(v).length;
89
+ }
90
+
91
+ /** 头部那句类型说明:`object · 6 项` / `array · 12 项` / `string · 4,096 字符` */
92
+ function typeLabelOf(v) {
93
+ if (Array.isArray(v)) return `array · ${v.length} 项`;
94
+ if (isStructured(v)) return `object · ${Object.keys(v).length} 项`;
95
+ if (typeof v === 'string') return `string · ${v.length} 字符`;
96
+ return typeof v;
97
+ }
98
+
99
+ /** 基本类型的着色类与展示文本 */
100
+ function primitive(v) {
101
+ if (v === null) return { cls: 'tr-full-null', text: 'null' };
102
+ if (v === undefined) return { cls: 'tr-full-null', text: 'undefined' };
103
+ const t = typeof v;
104
+ if (t === 'string') {
105
+ const clipped = v.length > MAX_STR ? v.slice(0, MAX_STR) : v;
106
+ const rest = v.length > MAX_STR ? `… 还有 ${v.length - MAX_STR} 字符` : '';
107
+ return { cls: 'tr-full-str', text: JSON.stringify(clipped) + rest };
108
+ }
109
+ if (t === 'number') return { cls: 'tr-full-num', text: String(v) };
110
+ if (t === 'boolean') return { cls: 'tr-full-bool', text: String(v) };
111
+ return { cls: 'tr-full-null', text: String(v) };
112
+ }
113
+
114
+ /** 深层容器压成单行时的表示:保留"它是个容器"这个事实,不伪装成标量 */
115
+ function oneLine(v) {
116
+ const kind = Array.isArray(v) ? 'array' : 'object';
117
+ const s = safeStringify(v).replace(/\s+/g, ' ');
118
+ const clipped = s.length > MAX_STR ? s.slice(0, MAX_STR) + '…' : s;
119
+ return `<${kind} · ${sizeOf(v)} 项> ${clipped}`;
120
+ }
121
+
122
+ /** 子键的 JSON 路径:对象用 `.key`,数组用 `[i]`。根是空串,所以 `[0]` 不会变成 `.[0]` */
123
+ function pathOf(parent, key, isArr) {
124
+ return isArr ? `${parent}[${key}]` : parent ? `${parent}.${key}` : key;
125
+ }
126
+
127
+ /** 剪贴板:没有实现时**什么都不做**,绝不把没做到的事显示成「已复制」 */
128
+ function copyInto(btn, text, done) {
129
+ const clip = globalThis.navigator?.clipboard;
130
+ if (!clip || typeof clip.writeText !== 'function') return;
131
+ Promise.resolve(clip.writeText(text)).then(
132
+ () => {
133
+ btn.textContent = done;
134
+ },
135
+ () => {
136
+ btn.textContent = '复制失败';
137
+ },
138
+ );
139
+ }
140
+
141
+ /** 正在选文本时不要收起/展开 —— 鼠标拖选到行外松手会补一次 click,否则选完就自己合上了 */
142
+ function hasSelection() {
143
+ try {
144
+ return String(globalThis.getSelection?.() ?? '') !== '';
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ /** 悬停才露出的 `⧉`:点它复制路径,**不**触发所在容器的折叠 */
151
+ function pathBtn(path) {
152
+ const btn = fvEl('span', 'tr-full-path', '⧉');
153
+ btn.title = '复制路径 ' + path;
154
+ btn.addEventListener('click', (e) => {
155
+ // 真实 DOM 里会冒泡到容器行的折叠处理;stub 的 click() 传的是空对象,故双保险
156
+ if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
157
+ copyInto(btn, path, '已复制');
158
+ });
159
+ return btn;
160
+ }
161
+
162
+ /**
163
+ * 这一层要不要铺开。显式折叠/展开优先(`fold` 里有记录),否则按深度给默认值。
164
+ * @param {Map<string, boolean>} fold 路径 → 是否展开
165
+ */
166
+ function isOpen(fold, path, depth) {
167
+ return fold.has(path) ? fold.get(path) === true : depth < OPEN_DEPTH;
168
+ }
169
+
170
+ /**
171
+ * 同构对象数组 → 列名;不是"真同构"就返回 null(调用方回落树形)。
172
+ *
173
+ * 判据全部是「表格会丢东西」的地方,松一条就丢一类数据:元素必须是普通对象、
174
+ * 键集**完全一致**(缺键/多键都回落)、列数 1..6、每列都是标量(表格里没法逐层展开)。
175
+ */
176
+ function tableColsOf(arr) {
177
+ if (!Array.isArray(arr) || arr.length < 2 || arr.length > MAX_ITEMS) return null;
178
+ if (!isPlainObject(arr[0])) return null;
179
+ const cols = Object.keys(arr[0]);
180
+ if (cols.length === 0 || cols.length > MAX_COLS) return null;
181
+ for (const item of arr) {
182
+ if (!isPlainObject(item)) return null;
183
+ if (Object.keys(item).length !== cols.length) return null;
184
+ for (const c of cols) {
185
+ if (!Object.hasOwn(item, c)) return null;
186
+ if (isStructured(item[c])) return null;
187
+ }
188
+ }
189
+ return cols;
190
+ }
191
+
192
+ /**
193
+ * 同构数组铺成表格。缩进用 `data-depth`(值为 0 时不写)—— CSS 靠属性选择器补左外边距,
194
+ * 因为列数是动态的、行数也是动态的,没有一个能写死的 inline style 可靠活在 stub 里。
195
+ */
196
+ function renderTable(ctx, arr, cols, depth, path) {
197
+ const table = fvEl('table', 'tr-full-table');
198
+ if (depth > 0) table.dataset.depth = String(depth);
199
+
200
+ const thead = fvEl('thead');
201
+ const headRow = fvEl('tr');
202
+ for (const c of cols) {
203
+ const th = fvEl('th');
204
+ th.appendChild(fvEl('span', 'tr-full-col', c));
205
+ // 列的路径带 `[*]`:复制出去能直接用于过滤/取值,而不是某一行的那一份
206
+ th.appendChild(pathBtn(pathOf(path + '[*]', c, false)));
207
+ headRow.appendChild(th);
208
+ }
209
+ thead.appendChild(headRow);
210
+ table.appendChild(thead);
211
+
212
+ const tbody = fvEl('tbody');
213
+ for (const item of arr) {
214
+ const tr = fvEl('tr');
215
+ for (const c of cols) {
216
+ const td = fvEl('td');
217
+ const p = primitive(item[c]); // 表里同样截断并说出剩余(不做「表格例外」)
218
+ td.appendChild(fvEl('span', p.cls, p.text));
219
+ tr.appendChild(td);
220
+ }
221
+ tbody.appendChild(tr);
222
+ }
223
+ table.appendChild(tbody);
224
+ ctx.body.appendChild(table);
225
+ }
226
+
227
+ /** 缩进空格的落点:0 层不占位(否则每条顶级键都白带两个空格) */
228
+ function indentOf(depth) {
229
+ return depth > 0 ? fvEl('span', 'tr-full-indent', ' '.repeat(depth)) : null;
230
+ }
231
+
232
+ /** 折叠箭头的槽位。叶子行也占一格(传空串),否则同层键名会在「有箭头」与「没箭头」之间
233
+ * 左右跳 10px —— 一列键名对齐是这类面板最基本的可读性,不能靠行首参差来区分有没有子项。 */
234
+ function caretSlot(glyph) {
235
+ return fvEl('span', 'tr-full-caret', glyph);
236
+ }
237
+
238
+ /** 一行 = 缩进 + 箭头槽位(+ 稍后追加的键名/值)。所有行都从同一个入口起头,
239
+ * 这样才能保证「同一层的键名在同一条竖线上」—— 逐处手写就迟早漏一处。 */
240
+ function startRow(depth, caret) {
241
+ const row = fvEl('div', 'tr-full-line');
242
+ const indent = indentOf(depth);
243
+ if (indent) row.appendChild(indent);
244
+ row.appendChild(caretSlot(caret));
245
+ return row;
246
+ }
247
+
248
+ /**
249
+ * 把一个值铺进 `ctx.body`。
250
+ * @param {{body: Element, fold: Map<string, boolean>, repaint: () => void}} ctx
251
+ * @param {any} v 值
252
+ * @param {number} depth 当前缩进层级(根为 0)
253
+ * @param {string} path 当前值的 JSON 路径(根为空串)
254
+ */
255
+ function renderInto(ctx, v, depth, path) {
256
+ const box = ctx.body;
257
+
258
+ if (!isStructured(v)) {
259
+ const p = primitive(v);
260
+ const row = startRow(depth, '');
261
+ row.appendChild(fvEl('span', p.cls, p.text));
262
+ box.appendChild(row);
263
+ return;
264
+ }
265
+
266
+ const isArr = Array.isArray(v);
267
+ const cols = isArr ? tableColsOf(v) : null;
268
+ if (cols) {
269
+ renderTable(ctx, v, cols, depth, path);
270
+ return;
271
+ }
272
+
273
+ const keys = isArr ? v.map((_, i) => String(i)) : Object.keys(v);
274
+ const show = keys.slice(0, MAX_ITEMS);
275
+
276
+ for (const k of show) {
277
+ const child = isArr ? v[Number(k)] : v[k];
278
+ const keyText = isArr ? `[${k}]` : `${k}:`;
279
+ const childPath = pathOf(path, k, isArr);
280
+ // 「能否折叠」只看三件事:是容器、非空、没到深度上限 —— 与下面几个分支同一套判据。
281
+ // 先算出来是为了让箭头槽位在起行时就有正确字形(叶子行留空格位,键名才会对齐)。
282
+ const foldable = isStructured(child) && sizeOf(child) > 0 && depth < MAX_DEPTH;
283
+ const open = foldable && isOpen(ctx.fold, childPath, depth);
284
+ const row = startRow(depth, foldable ? (open ? '▾' : '▸') : '');
285
+
286
+ // 叶子:同一行里写完(缩进 + 键名 + 路径 + 值)
287
+ if (!isStructured(child)) {
288
+ const p = primitive(child);
289
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
290
+ row.appendChild(pathBtn(childPath));
291
+ row.appendChild(fvEl('span', p.cls, ' ' + p.text));
292
+ box.appendChild(row);
293
+ continue;
294
+ }
295
+
296
+ // 深度用尽 ⇒ 整体压成一行(并标明它是个容器),避免缩进吃掉全部宽度
297
+ if (depth >= MAX_DEPTH) {
298
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
299
+ row.appendChild(fvEl('span', 'tr-full-null', ' ' + oneLine(child)));
300
+ box.appendChild(row);
301
+ continue;
302
+ }
303
+
304
+ // 空容器:留一对括号,别让它看起来像"展开失败"
305
+ if (sizeOf(child) === 0) {
306
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
307
+ row.appendChild(fvEl('span', 'tr-full-null', Array.isArray(child) ? ' []' : ' {}'));
308
+ box.appendChild(row);
309
+ continue;
310
+ }
311
+
312
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
313
+ row.appendChild(pathBtn(childPath));
314
+ if (!open) {
315
+ const kind = Array.isArray(child) ? 'array' : 'object';
316
+ row.appendChild(fvEl('span', 'tr-full-hint', ` { ${kind} · ${sizeOf(child)} 项 }`));
317
+ } else if (Array.isArray(child) && tableColsOf(child)) {
318
+ // 成表时行内不铺子项,只在键名后标一句规模 —— 否则 `▾ targets:` 后面直接跟着表格,
319
+ // 看不出这是个数组、也看不出表格是不是全部内容(是不是被截断了)
320
+ row.appendChild(fvEl('span', 'tr-full-hint', ` array · ${child.length} 项`));
321
+ }
322
+ row.dataset.fold = childPath;
323
+ row.addEventListener('click', (e) => {
324
+ if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
325
+ if (hasSelection()) return;
326
+ ctx.fold.set(childPath, !open);
327
+ ctx.repaint();
328
+ });
329
+ box.appendChild(row);
330
+
331
+ if (open) renderInto(ctx, child, depth + 1, childPath);
332
+ }
333
+
334
+ if (keys.length > show.length) {
335
+ const row = startRow(depth, '');
336
+ row.appendChild(fvEl('span', 'tr-full-null', `… 还有 ${keys.length - show.length} 项未展开`));
337
+ box.appendChild(row);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * 建一个结构化正文块。
343
+ * @param {any} value `ev.full` 的**原值**(不是序列化后的字符串)
344
+ * @param {{fold?: Map<string, boolean>}} [opts]
345
+ * `fold` 是**调用方持有**的折叠状态(路径 → 是否展开)。省略时本块自建一份,
346
+ * 此时折叠状态活不过下一次「全量重建」—— 只对一次性渲染(测试、静态快照)够用。
347
+ * @returns {Element} `.tr-full` 块;调用方负责 append 到展开行之后
348
+ */
349
+ function createFullView(value, opts = {}) {
350
+ const fold = opts.fold instanceof Map ? opts.fold : new Map();
351
+
352
+ const box = fvEl('div', 'tr-full');
353
+ box.dataset.kind = Array.isArray(value) ? 'array' : 'object';
354
+
355
+ const head = fvEl('div', 'tr-full-head');
356
+ head.appendChild(fvEl('span', 'tr-full-type', typeLabelOf(value)));
357
+ const copy = fvEl('span', 'tr-full-copy', '复制');
358
+ copy.dataset.copy = '1';
359
+ copy.addEventListener('click', () => copyInto(copy, safeStringify(value), '已复制'));
360
+ head.appendChild(copy);
361
+ box.appendChild(head);
362
+
363
+ const body = fvEl('div', 'tr-full-body');
364
+ const ctx = { body, fold, repaint: null };
365
+ // 只重画正文(不动头部):折叠不需要重建整棵树,滚动位置与头部反馈都留着
366
+ ctx.repaint = () => {
367
+ body.innerHTML = '';
368
+ renderInto(ctx, value, 0, '');
369
+ };
370
+ ctx.repaint();
371
+
372
+ box.appendChild(body);
373
+ return box;
374
+ }
375
+
5
376
  /* ===== src/view.js ===== */
6
377
  /* Agentia trace 调用树渲染器 —— 数据无关、零依赖。
7
378
  *
@@ -22,6 +393,13 @@
22
393
  * 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
23
394
  */
24
395
 
396
+ /* 展开态的正文有两种形状,由 `full` 的类型决定(2026-11-11):
397
+ * - 字符串 → 老路:`.tr-io` 里放原文,靠 `.tr-open` 换行(纯文本正文、官网模拟回放);
398
+ * - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS 隐藏。
399
+ * 两档并存是刻意的:字符串那条路有 test/style.test.js 的两个不变量守着(最小宽度、caret 常显),
400
+ * 全换成本件会让那两个守卫失去落点。 */
401
+
402
+
25
403
  const CAP_ICO = { tool: '⚙', skill: '◆', prompt: '¶', subagent: '⊕' };
26
404
 
27
405
  /** 能力类型取自名字前缀(tool: / skill: / prompt: / subagent:) */
@@ -93,6 +471,9 @@ function createTraceView(rootEl, opts = {}) {
93
471
  /* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
94
472
  渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
95
473
  const expanded = new Set();
474
+ /* 结构化块内部的折叠状态,按事件键一份(`ev.key` → 路径 → 是否展开)。
475
+ 与 `expanded` 同一条理由,而且更硬:块本身每次重建都会 new 一个,状态只能挂在树上。 */
476
+ const folds = new Map();
96
477
  let evSeq = 0;
97
478
 
98
479
  /** 正在选文本时不要收起/展开 —— 鼠标拖选到行外松手会补一次 click,否则选完就自己合上了 */
@@ -141,20 +522,35 @@ function createTraceView(rootEl, opts = {}) {
141
522
  // 非 tool.* 事件(usage.unpriced / llm.retry / budget.* / context.budget)没有工具名 ——
142
523
  // 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
143
524
  if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
144
- // 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致
145
- const io = el('span', 'tr-io', (open ? ev.full : ev.text) || '');
525
+ // 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致。
526
+ // `full` 是对象/数组时(2026-11-11):`.tr-io` 仍放**原文**(结构化块挂在行后、由 CSS
527
+ // 隐藏本格),所以「展开拿到的是原文而不是 62 字符摘要」这条不变量原样成立。
528
+ const structured = ev.full !== null && typeof ev.full === 'object';
529
+ const fullText = structured ? rawArg(ev.full) : ev.full || '';
530
+ const io = el('span', 'tr-io', (open ? fullText : ev.text) || '');
146
531
  // 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
147
532
  io.title = open ? '' : ev.text || '';
148
533
  row.appendChild(io);
149
534
  if (ev.full) {
150
535
  row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
151
536
  row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
152
- if (open) row.className += ' tr-open';
537
+ if (open) row.className += ' tr-open' + (structured ? ' tr-structured' : '');
153
538
  row.addEventListener('click', () => {
154
539
  if (!hasSelection()) toggleExpand(ev.key);
155
540
  });
156
541
  }
157
542
  rootEl.appendChild(row);
543
+ // 结构化正文是**兄弟块**而不是 `.tr-io` 的子节点:行是 flex + nowrap,
544
+ // 塞进去会把树的列对齐打乱;独立成块才能自己换行、自己缩进。
545
+ // 折叠状态按事件键取一份带进去(2026-11-11 晚):块每次重建都 new,状态不能放块里。
546
+ if (open && structured) {
547
+ let fold = folds.get(ev.key);
548
+ if (!fold) {
549
+ fold = new Map();
550
+ folds.set(ev.key, fold);
551
+ }
552
+ rootEl.appendChild(createFullView(ev.full, { fold }));
553
+ }
158
554
  return;
159
555
  }
160
556
 
@@ -229,6 +625,7 @@ function createTraceView(rootEl, opts = {}) {
229
625
  spanMap.clear();
230
626
  spanMap.set('root', traceRoot);
231
627
  expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
628
+ folds.clear(); // 块内折叠同理:留着只会在下一次点开时出现"莫名已折叠"的状态
232
629
  usageAcc.input = 0;
233
630
  usageAcc.output = 0;
234
631
  usageAcc.cacheRead = 0;
@@ -409,15 +806,24 @@ function eventText(ev) {
409
806
  }
410
807
 
411
808
  /**
412
- * 事件全文(展开态用)。
809
+ * 事件全文(展开态用)—— **给原值,不预先序列化**。
413
810
  *
414
- * 只有 `tool.input` 需要单独取:`fmtArg` 把入参摘要**砍到 4 个键 / 每值 21 字符 / 整串 62 字符**,
415
- * 直接拿摘要当全文就是「假展开」—— 点开看到的还是那 62 个字符。出参与其他事件本身就是原文
416
- * (`eventText` 原样返回 content),无需二次处理。
417
- * 原文口径统一在 view.js 的 `rawArg`:官网 playground 的模拟回放也要用它,两处一处定义。
811
+ * 2026-11-11 起 `full` 的类型本身携带语义:字符串 ⇒ 渲染器走 `.tr-io` 的纯文本换行;
812
+ * 对象/数组 ⇒ 交给 `full-view.js` 铺成带缩进的结构化块。所以这里必须把**原值**递下去 ——
813
+ * 若仍在这里 `rawArg` 成字符串,「一大坨 JSON」那条路就永远回不来了(渲染器只能看到字符串)。
814
+ *
815
+ * 口径:
816
+ * - `tool.input` → 入参原值(`fmtArg` 的摘要砍到 4 键 / 每值 21 字符 / 整串 62 字符,
817
+ * 拿摘要当全文就是「假展开」);
818
+ * - `tool.output` → `content` 原值:字符串就是纯文本(含图片块时会是非字符串,正好结构化);
819
+ * - 其余事件 → `eventText` 的字符串(它们的体本来就是给人看的一小段说明)。
418
820
  */
419
821
  function eventFull(ev) {
420
- if (ev.name === 'tool.input') return rawArg(ev.body?.input);
822
+ if (ev.name === 'tool.input') return ev.body?.input ?? '';
823
+ if (ev.name === 'tool.output') {
824
+ const c = ev.body?.content;
825
+ return c == null ? '' : c;
826
+ }
421
827
  return eventText(ev);
422
828
  }
423
829
 
@@ -680,9 +1086,18 @@ function escapeHtml(s) {
680
1086
  /* @migor/trace-view —— Agentia trace 调用树渲染器(框架无关、零依赖)。
681
1087
  * 官网 playground 与 CLI inspector 共用同一份,避免两处渲染漂移。 */
682
1088
 
1089
+
1090
+
1091
+
1092
+ /* 结构化正文块(2026-11-11):`full` 是对象/数组时,渲染器会给展开行挂上这个块。
1093
+ * 单独导出是因为它是**纯函数式的 DOM 构造**(入参 → 元素),宿主可以拿它渲染任意值 ——
1094
+ * 而 bundle 的导出面必须与这里逐字一致(`test/bundle.test.js`),所以内部件不进公共面、
1095
+ * 这一件必须进。 */
1096
+
683
1097
  export {
684
1098
  CAP_ICO,
685
1099
  capabilityTypeOf,
1100
+ createFullView,
686
1101
  createTraceView,
687
1102
  fmtArg,
688
1103
  fmtMs,
@@ -217,3 +217,147 @@
217
217
  word-break: break-word;
218
218
  min-width: 0;
219
219
  }
220
+
221
+ /* —— 结构化正文(2026-11-11):`full` 是对象/数组时,展开行**下方**多一个兄弟块 ——
222
+ 此前只有一条展开路:整串无缩进的 JSON 塞进 `.tr-io` 再靠 `.tr-open` 换行,20 个键就在
223
+ 400px 宽的右栏里折成十几行连续文本(页面不报错、无溢出,只是人读不了)。
224
+
225
+ 为什么是兄弟块、不是 `.tr-io` 的子节点:`.tr-row` 是 flex + nowrap,往里塞块级内容会把
226
+ 树的列对齐打乱;独立成块才能自己换行、自己缩进、自己滚。
227
+
228
+ 为什么 `.tr-io` 只是**隐藏**而不是不渲染:它仍持有原文(`textContent` = `JSON.stringify`),
229
+ `test/view.test.js` 的「展开拿到的是原文、不是摘要」与用户选中复制都靠它 ——
230
+ 这里改的是画法,不是数据。 */
231
+ .tr-row.tr-structured .tr-io {
232
+ display: none;
233
+ }
234
+ .tr-full {
235
+ margin: 2px 0 8px 2.2em; /* 与 `.tr-pre` 的树形前缀对齐,不跟父行抢左侧 */
236
+ border-left: 2px solid #2a2a31;
237
+ padding: 4px 0 4px 10px;
238
+ font-size: 11.5px;
239
+ line-height: 1.55;
240
+ /* 与 `.tr-row.tr-open .tr-io` 同一个高度上限:出参动辄上千行,不限高会把整棵树推出屏幕 */
241
+ max-height: 60vh;
242
+ overflow: auto;
243
+ white-space: normal;
244
+ }
245
+ .tr-full-head {
246
+ display: flex;
247
+ gap: 10px;
248
+ margin-bottom: 2px;
249
+ font-size: 10.5px;
250
+ color: var(--faint);
251
+ }
252
+ .tr-full-copy {
253
+ color: var(--ice);
254
+ cursor: pointer;
255
+ }
256
+ .tr-full-copy:hover {
257
+ text-decoration: underline;
258
+ }
259
+ /* 每一行自己换行:长字符串(工具出参常见)必须能折,否则横向滚动条把缩进吃掉 */
260
+ .tr-full-line {
261
+ white-space: pre-wrap;
262
+ overflow-wrap: anywhere;
263
+ border-radius: 3px;
264
+ }
265
+ /* 可折叠的行(有 data-fold)才给 hover 反馈 —— 叶子行点了没事发生,就不该有可点的样子 */
266
+ .tr-full-line[data-fold] {
267
+ cursor: pointer;
268
+ }
269
+ .tr-full-line[data-fold]:hover {
270
+ background: #17171a;
271
+ }
272
+ /* 折叠箭头与 .tr-caret 不同:它不是「这一行能展开」的唯一指示(同一行还写着 `{ object · 4 项 }`,
273
+ 折叠态的值本身就说明了一切),所以不做常显的对比度承诺,跟着 hover 走。
274
+ **定宽**:叶子行也占这一格(见 full-view.js 的 caretSlot),否则同层键名会在有无箭头之间
275
+ 左右跳 —— 一列键名对齐是这类面板最基本的可读性。 */
276
+ .tr-full-caret {
277
+ display: inline-block;
278
+ width: 10px;
279
+ text-align: center;
280
+ color: var(--faint);
281
+ font-size: 10px;
282
+ margin-right: 3px;
283
+ }
284
+ .tr-full-hint {
285
+ color: var(--faint);
286
+ }
287
+ /* `⧉` 复制路径:hover 才露。与 .tr-caret 的区别是**它暴露的是动作、不是信息** ——
288
+ 路径的内容(键名)始终写在行里,常显只会给每行多一个字形噪声。
289
+ 没有剪贴板实现时照样显示、点了不谎报成功(见 full-view.js 的 copyInto)。 */
290
+ .tr-full-path {
291
+ opacity: 0;
292
+ color: var(--ice);
293
+ cursor: pointer;
294
+ font-size: 10px;
295
+ margin: 0 4px;
296
+ transition: opacity 0.12s;
297
+ }
298
+ .tr-full-line:hover .tr-full-path,
299
+ .tr-full-table th:hover .tr-full-path {
300
+ opacity: 1;
301
+ }
302
+ /* —— 同构对象数组 → 表格(2026-11-11 晚)——
303
+ 3 行 `{name,score}` 在树形里要花 9 行重复键名,成表后是 1 行表头 + 3 行数据,
304
+ 而且列天然对齐。只在「真同构且全标量列」时启用(判据在 full-view.js 的 tableColsOf),
305
+ 否则回落树形 —— 表格里没法逐层展开,判错一次就是丢数据。 */
306
+ .tr-full-table {
307
+ border-collapse: collapse;
308
+ margin: 2px 0 4px;
309
+ font-size: 11.5px;
310
+ }
311
+ /* 缩进:列数/行数都是动态的,没有能写死的 inline style(stub 里也没有 style 字段),
312
+ 所以按层级给属性选择器 —— 0 层不写 data-depth,自然不缩进。 */
313
+ .tr-full-table[data-depth="1"] {
314
+ margin-left: 2ch;
315
+ }
316
+ .tr-full-table[data-depth="2"] {
317
+ margin-left: 4ch;
318
+ }
319
+ .tr-full-table[data-depth="3"] {
320
+ margin-left: 6ch;
321
+ }
322
+ .tr-full-table[data-depth="4"] {
323
+ margin-left: 8ch;
324
+ }
325
+ .tr-full-table[data-depth="5"] {
326
+ margin-left: 10ch;
327
+ }
328
+ .tr-full-table th {
329
+ text-align: left;
330
+ font-weight: 500;
331
+ color: #71717a;
332
+ padding: 0 12px 1px 0;
333
+ border-bottom: 1px solid #27272a;
334
+ white-space: nowrap;
335
+ }
336
+ .tr-full-table td {
337
+ padding: 1px 12px 1px 0;
338
+ white-space: pre-wrap;
339
+ overflow-wrap: anywhere;
340
+ }
341
+ .tr-full-table tbody tr:hover {
342
+ background: #17171a;
343
+ }
344
+ /* 缩进用真空格 + `pre`,不靠 padding —— 嵌套层级要能被**选中复制**带出去 */
345
+ .tr-full-indent {
346
+ white-space: pre;
347
+ }
348
+ .tr-full-key {
349
+ color: #a1a1aa;
350
+ margin-right: 2px;
351
+ }
352
+ .tr-full-str {
353
+ color: #9ece6a;
354
+ }
355
+ .tr-full-num {
356
+ color: #e0af68;
357
+ }
358
+ .tr-full-bool {
359
+ color: #7dcfff;
360
+ }
361
+ .tr-full-null {
362
+ color: var(--faint);
363
+ }
@@ -17,6 +17,13 @@
17
17
  * 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
18
18
  */
19
19
 
20
+ /* 展开态的正文有两种形状,由 `full` 的类型决定(2026-11-11):
21
+ * - 字符串 → 老路:`.tr-io` 里放原文,靠 `.tr-open` 换行(纯文本正文、官网模拟回放);
22
+ * - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS 隐藏。
23
+ * 两档并存是刻意的:字符串那条路有 test/style.test.js 的两个不变量守着(最小宽度、caret 常显),
24
+ * 全换成本件会让那两个守卫失去落点。 */
25
+ import { createFullView } from './full-view.js';
26
+
20
27
  export const CAP_ICO = { tool: '⚙', skill: '◆', prompt: '¶', subagent: '⊕' };
21
28
 
22
29
  /** 能力类型取自名字前缀(tool: / skill: / prompt: / subagent:) */
@@ -88,6 +95,9 @@ export function createTraceView(rootEl, opts = {}) {
88
95
  /* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
89
96
  渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
90
97
  const expanded = new Set();
98
+ /* 结构化块内部的折叠状态,按事件键一份(`ev.key` → 路径 → 是否展开)。
99
+ 与 `expanded` 同一条理由,而且更硬:块本身每次重建都会 new 一个,状态只能挂在树上。 */
100
+ const folds = new Map();
91
101
  let evSeq = 0;
92
102
 
93
103
  /** 正在选文本时不要收起/展开 —— 鼠标拖选到行外松手会补一次 click,否则选完就自己合上了 */
@@ -136,20 +146,35 @@ export function createTraceView(rootEl, opts = {}) {
136
146
  // 非 tool.* 事件(usage.unpriced / llm.retry / budget.* / context.budget)没有工具名 ——
137
147
  // 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
138
148
  if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
139
- // 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致
140
- const io = el('span', 'tr-io', (open ? ev.full : ev.text) || '');
149
+ // 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致。
150
+ // `full` 是对象/数组时(2026-11-11):`.tr-io` 仍放**原文**(结构化块挂在行后、由 CSS
151
+ // 隐藏本格),所以「展开拿到的是原文而不是 62 字符摘要」这条不变量原样成立。
152
+ const structured = ev.full !== null && typeof ev.full === 'object';
153
+ const fullText = structured ? rawArg(ev.full) : ev.full || '';
154
+ const io = el('span', 'tr-io', (open ? fullText : ev.text) || '');
141
155
  // 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
142
156
  io.title = open ? '' : ev.text || '';
143
157
  row.appendChild(io);
144
158
  if (ev.full) {
145
159
  row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
146
160
  row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
147
- if (open) row.className += ' tr-open';
161
+ if (open) row.className += ' tr-open' + (structured ? ' tr-structured' : '');
148
162
  row.addEventListener('click', () => {
149
163
  if (!hasSelection()) toggleExpand(ev.key);
150
164
  });
151
165
  }
152
166
  rootEl.appendChild(row);
167
+ // 结构化正文是**兄弟块**而不是 `.tr-io` 的子节点:行是 flex + nowrap,
168
+ // 塞进去会把树的列对齐打乱;独立成块才能自己换行、自己缩进。
169
+ // 折叠状态按事件键取一份带进去(2026-11-11 晚):块每次重建都 new,状态不能放块里。
170
+ if (open && structured) {
171
+ let fold = folds.get(ev.key);
172
+ if (!fold) {
173
+ fold = new Map();
174
+ folds.set(ev.key, fold);
175
+ }
176
+ rootEl.appendChild(createFullView(ev.full, { fold }));
177
+ }
153
178
  return;
154
179
  }
155
180
 
@@ -224,6 +249,7 @@ export function createTraceView(rootEl, opts = {}) {
224
249
  spanMap.clear();
225
250
  spanMap.set('root', traceRoot);
226
251
  expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
252
+ folds.clear(); // 块内折叠同理:留着只会在下一次点开时出现"莫名已折叠"的状态
227
253
  usageAcc.input = 0;
228
254
  usageAcc.output = 0;
229
255
  usageAcc.cacheRead = 0;
@@ -108,6 +108,10 @@ const STATIC = new Map([
108
108
  ['index.js', join(ASSETS, 'index.js')],
109
109
  ['view.js', join(ASSETS, 'view.js')],
110
110
  ['fromTrace.js', join(ASSETS, 'fromTrace.js')],
111
+ /* 结构化展开正文(2026-11-11):`full` 是对象/数组时,view.js 用它铺缩进/着色/复制块。
112
+ * 与上面四个同性质 —— 渲染器的一部分,页面**会** import 它(view.js 的静态 import 被
113
+ * 剥成页面级 import 时它必须可达),漏登记 = 浏览器 404 = 面板整块模块求值失败。 */
114
+ ['full-view.js', join(ASSETS, 'full-view.js')],
111
115
  ['summary.js', join(ASSETS, 'summary.js')],
112
116
  ['trace-view.css', join(ASSETS, 'trace-view.css')],
113
117
  /* 单文件 bundle(`agentia view` 内联进自包含 HTML 用)。⚠️ **页面不 import 它** ——
@@ -11,10 +11,10 @@
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json"
12
12
  },
13
13
  "dependencies": {
14
- "@migor/agentia": "^0.10.6"
14
+ "@migor/agentia": "^0.10.7"
15
15
  },
16
16
  "devDependencies": {
17
- "@migor/cli": "^0.10.6",
17
+ "@migor/cli": "^0.10.7",
18
18
  "tsx": "^4.19.0",
19
19
  "typescript": "^7.0.2",
20
20
  "@types/node": "^22.0.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.10.6",
3
+ "version": "0.10.7",
4
4
  "description": "CLI for the Agentia framework: scaffold a project, generate a capability, and open a local trace inspector for runs.",
5
5
  "type": "module",
6
6
  "bin": {