@migor/cli 0.9.1 → 0.9.2

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,7 +5,64 @@
5
5
  (0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
6
6
  决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
7
7
 
8
- ## [Unreleased]
8
+ ## [0.9.2] - 2026-09-22
9
+
10
+ > 本版主题(窗口 `0.9.1 → 0.9.2`):**dev 面板的可读性** —— 模型正文按 Markdown 渲染、
11
+ > 滚动分层(整页不滚、只滚该滚的那块)、长正文默认折叠。
12
+ > **无破坏性变更**:框架公共 API 与脚手架模板形态逐字未变。
13
+
14
+ ### 新增 · dev 面板的正文 Markdown 渲染
15
+
16
+ - **模型正文按 Markdown 渲染**(最终回复 + 对话视图的助手轮):标题 / 粗斜体 / 行内码 /
17
+ 围栏码(带语言标注)/ 列表 / 引用 / 分隔线 / 链接。此前是一整块 `<pre>` 纯文本,
18
+ 长回复里的列表与代码块糊成一坨。
19
+ - 解析器是**自己写的**(`packages/cli/src/markdown.ts`,纯逻辑、零依赖、带单测):
20
+ 引 `marked` + `DOMPurify` 等于把两份第三方产物塞进 dev 链路,而且它们**没法被本仓单测验证**;
21
+ 这套子集不到 200 行,每条规则都有用例。安全前提是**按构造不产生 HTML** —— 解析器只产出
22
+ token 树(没有「HTML 透传」这一类),渲染层只准用 `createElement` / `textContent`
23
+ ⇒ 模型输出里的 `<script>` 只会是字面文本。链接协议过白名单(`http` / `https` / `mailto`),
24
+ 不合法(`javascript:` 等)**整条降级成字面文本**(连 `[]()` 一起显示)。用户轮保持字面 ——
25
+ 那是「我打的字」,按 Markdown 重排会像面板改过我的输入。
26
+
27
+ ### 变更 · 面板滚动分层:整页不滚,只滚该滚的那块
28
+
29
+ - 此前右栏没有高度上限 ⇒ 内容一长就是**整页滚动**,而 `footer` 还粘在底部盖住内容。
30
+ 现在改成应用外壳:`html/body` 卡 100vh、`body` 不滚;左栏页签固定、列表自己滚;
31
+ 右栏**只有调用树是滚动区**(它随 span 数无界增长),回复 / 能力排行 / 用量固定在下方、
32
+ 各有上限(回复很长时在**它自己那块**里滚,而不是把树推出屏幕)。
33
+ - 窄屏(≤860px)回落成单列 + 整页滚动 —— 两栏并排在那个宽度下都不可用。
34
+
35
+ ### 变更 · 长消息折叠:明确的「展开 / 收起」
36
+
37
+ - 超过 12 行或 1200 字符的正文默认折叠(卡 216px + 底部渐隐),下方左对齐一个
38
+ 「展开全文(共 N 行)▾」按钮,展开后变「收起 ▴」。**短正文不挂控件**(挂一个是噪声)。
39
+ - 判定走纯逻辑 `collapseDecision`(带单测):按**行数 + 字符数**,不量像素 —— 像素阈值在
40
+ 字体 / 缩放 / 窄屏下会漂,同一个回复在不同机器上折叠与否都不一样。展开态活在渲染之外
41
+ (面板每次全量重画,状态留在 DOM 里会被下一次重画合上)。
42
+
43
+ ### 修复
44
+
45
+ - **CLI 套件里两条文件监视用例在全链下稳定假红**(`packages/cli/test/panel-logic.test.mjs`):
46
+ 夹具原来「**写一次**、然后轮询等满 15 秒」,而真 fs 的监视器**建立是异步的**(`fs.watch`
47
+ 返回 ≠ 底下 FSEvents 流已开始投递)—— 那一次写整个漏掉时,**等多久都没用**。复现条件最后
48
+ 定位到 **stdout 被管道捕获**(`verify-all.sh` 的 `out=$(bash -c …)` 与 CI 都是这么跑的):
49
+ 管道下稳定红、重定向到文件稳定绿;单跑套件也是绿的。改成 `writeUntilSeen`(反复写、直到
50
+ 回调真的来,上限 15 秒)—— **断言没放宽**(那个路径仍必须触发一次回调,只是不再要求
51
+ 「第一次写就被看见」),四条「不该触发」的负向用例一条未动。
52
+ ⚠️ **仅测试侧,无使用者可见行为**:生产里 dev 环**先建立监视、再对外服务**,不存在
53
+ 「`watchTree()` 一返回用户就改文件」这个窗口。
54
+
55
+ ### 文档
56
+
57
+ - `docs/usage-guide.md` §2.2:按使用者口吻补三段 —— Markdown 渲染口径(含协议白名单与
58
+ 「用户轮保持字面」)、滚动三层各管一段、长正文折叠阈值与展开控件位置。
59
+ - `docs/guards.md` §1.4:新增 `markdown.test.mjs` 一行(两条安全断言:按构造不产生 HTML、
60
+ 链接协议白名单 + 两条反向验证),并把 `inspector.test.mjs` 那行的反向全覆盖**扩面**记上
61
+ (覆盖所有自建模块 + 四条页面级不变量)—— 是**扩面**,别读成「多了五条守卫」。
62
+ - `docs/spec.md` §10 2026-09-22 **⑨**:本轮三处可读性改动的现场证据与修法,外加两条踩坑
63
+ (反向验证的变异体**必须能编译**,否则构建先失败、门禁空跑;对被格式化过的文件做 patch
64
+ 时 `old_string` 用了格式化**前**的版本,靠「改完立刻回读」才发现)。
65
+ - `docs/plans/2026-09-22-dev-debug-loop.md`:补本轮三处的落地记录。
9
66
 
10
67
  ## [0.9.1] - 2026-09-22
11
68
 
@@ -44,9 +101,12 @@
44
101
  ### 文档
45
102
 
46
103
  - `docs/usage-guide.md` §2.2:补「右栏是实时的」(含两条边界)与工作目录的三个点击语义。
47
- - `docs/guards.md` §1.4:新增五条守卫登记(见该节)。
48
- - `docs/plans/2026-09-22-dev-debug-loop.md`:§D7 那条评审补充标注 **P1b 已落地**。
49
- - `docs/spec.md` §10 2026-09-22 ⑦:本轮三个缺陷的现场证据与修法(含「同一条事实两个表面」的第 N 个影子)。
104
+ - `docs/guards.md` §1.4:本轮在**既有三行**登记上扩面(`panel-logic.test.mjs` 增四组折回 / 归属 /
105
+ 浏览目标 / 选文件后的 prompt,`inspector.test.mjs` 增三项 HTTP 面,`e2e-dev.ts` 增第 7-bis 步),
106
+ 见该节 —— 是**扩面**不是新增行,别把它读成「多了五条守卫」。
107
+ - `docs/plans/2026-09-22-dev-debug-loop.md`:§D7 那条评审补充标注 **P1b 已落地**(增量出口接线)。
108
+ - `docs/spec.md` §10 2026-09-22 **⑧**:本轮三处体验缺陷的现场证据与修法(右栏实时 / 回复被擦 /
109
+ 选择器),外加一条发版流程教训(反向验证脚本必须先 commit)。
50
110
 
51
111
  ## [0.9.0] - 2026-09-22
52
112
 
@@ -1186,7 +1246,8 @@
1186
1246
  首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
1187
1247
  框架本体单包;CLI 独立成包(workspaces)。
1188
1248
 
1189
- [Unreleased]: https://github.com/retrychx/agentia/compare/v0.9.1...HEAD
1249
+ [Unreleased]: https://github.com/retrychx/agentia/compare/v0.9.2...HEAD
1250
+ [0.9.2]: https://github.com/retrychx/agentia/releases/tag/v0.9.2
1190
1251
  [0.9.1]: https://github.com/retrychx/agentia/releases/tag/v0.9.1
1191
1252
  [0.9.0]: https://github.com/retrychx/agentia/releases/tag/v0.9.0
1192
1253
  [0.8.3]: https://github.com/retrychx/agentia/releases/tag/v0.8.3
package/dist/AGENTS.md CHANGED
@@ -145,6 +145,19 @@ npx agentia --version # CLI 版本(= -v)
145
145
  早先那些 span 不会回来(收尾的整棵 trace 会补齐它们),**收尾那份永远覆盖在飞的这份**。
146
146
  - 它只影响「此刻看到什么」,**不影响 run 本身**,也不影响落盘的 trace(那是另一条缝)。
147
147
 
148
+ **模型正文按 Markdown 渲染**(最终回复 + 对话视图的助手轮):标题 / 粗斜体 / 行内码 / 围栏码 /
149
+ 列表 / 引用 / 分隔线 / 链接。解析器是本仓自己写的(零依赖),**只产出 token 树、不碰 HTML** ——
150
+ 模型输出里的 `<script>` 只会显示成字面文本;链接只认 `http` / `https` / `mailto`,其余协议
151
+ (`javascript:` 等)**整条降级成字面文本**(连 `[]()` 一起显示)。你自己那一轮保持字面 ——
152
+ 那是你打的字,面板不改写它。
153
+
154
+ **长正文默认折叠**:超过 12 行或 1200 字符的正文卡在 216px + 底部渐隐,下方左对齐一个
155
+ 「展开全文(共 N 行)▾」,展开后变「收起 ▴」。**短正文不挂控件**。
156
+
157
+ **滚动分三层,各管一段**:整页**不滚**(输入条永远贴在底部,不会被内容推走);左栏列表自己滚;
158
+ 右栏**只有调用树是滚动区** —— 它随 span 数无界增长,而回复 / 能力排行 / 用量固定在下方、
159
+ 各有上限(回复很长时在**它自己那块**里滚,不会把树挤出屏幕)。窄屏(≤860px)回落成单列 + 整页滚动。
160
+
148
161
  **为什么用 `agentia dev` 而不是 `tsx watch`**:`agentia dev` 自己 import 你的 `src/app.ts` 并驱动它,
149
162
  于是**它才是调用方**,四个输入才可能生效。它也**自己管文件监视**(不再叠一层 `tsx watch`):
150
163
 
@@ -43,15 +43,46 @@
43
43
  }
44
44
  #banner.err { border-color: var(--err); background: #1d1416; color: var(--err); }
45
45
 
46
+ /* 应用外壳:**整页不滚**,滚动交给各自的栏。
47
+ 为什么:内容一长就整页滚的话,「我正在看调用树」与「我在读回复」会互相把对方推走,
48
+ 而且 footer 还粘在底部盖住内容 —— 三个滚动条(页面 / 树 / 展开的行)叠在一起最难用。 */
49
+ html, body { height: 100%; }
50
+ body { overflow: hidden; display: flex; flex-direction: column; }
51
+ header, #banner, footer { flex: none; }
46
52
  main {
53
+ flex: 1 1 auto; min-height: 0;
47
54
  display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 14px;
48
55
  padding: 14px 20px;
49
56
  }
50
57
  aside, section {
51
58
  background: var(--card); border: 1px solid var(--border); border-radius: 6px;
59
+ min-height: 0;
60
+ }
61
+ /* 左栏:页签/清空条固定,列表自己滚 */
62
+ aside { display: flex; flex-direction: column; overflow: hidden; }
63
+ #tabs, #chat-bar { flex: none; }
64
+ #runs, #chat { flex: 1 1 auto; min-height: 0; overflow: auto; }
65
+ /* 右栏分层:`#trace` 是**唯一**无界增长的部分(它随 span 数长)⇒ 只有它 flex 撑开并滚;
66
+ 回复 / 能力排行 / 用量固定在下方,各自有上限(回复很长时在**它自己那块**里滚,
67
+ 而不是把树推出屏幕 —— 这与「看树」和「读回复」两个动作各自独立是一致的)。 */
68
+ section {
69
+ padding: 14px; font-family: var(--mono); font-size: 12px;
70
+ display: flex; flex-direction: column; overflow: hidden;
71
+ }
72
+ #ttl { flex: none; }
73
+ #trace { flex: 1 1 auto; min-height: 140px; overflow: auto; }
74
+ #summary, #reply, .usage { flex: none; max-height: 38vh; overflow: auto; }
75
+ /* 窄屏:两栏并排已经太窄 ⇒ 回到单列 + 整页滚动(此时「整页滚」才是唯一合理的形态) */
76
+ @media (max-width: 860px) {
77
+ html, body { height: auto; }
78
+ body { overflow: visible; }
79
+ main { grid-template-columns: minmax(0, 1fr); }
80
+ aside { overflow: visible; }
81
+ section { overflow: visible; }
82
+ #runs, #chat, #trace { overflow: visible; }
83
+ #trace { min-height: 320px; }
84
+ #summary, #reply, .usage { max-height: none; }
52
85
  }
53
- aside { overflow: hidden auto; max-height: calc(100vh - 200px); }
54
- section { padding: 14px; min-height: 240px; font-family: var(--mono); font-size: 12px; overflow: auto; }
55
86
  .run {
56
87
  display: block; width: 100%; text-align: left; cursor: pointer;
57
88
  background: none; border: 0; border-bottom: 1px solid var(--border);
@@ -102,10 +133,50 @@
102
133
 
103
134
  #reply {
104
135
  margin: 14px 0 0; padding: 10px; border: 1px solid var(--border); border-radius: 4px;
105
- background: #0e0e10; white-space: pre-wrap; word-break: break-word; color: var(--muted);
136
+ background: #0e0e10; word-break: break-word; color: var(--muted);
106
137
  }
107
138
  #reply[hidden] { display: none; }
108
139
 
140
+ /* —— 模型正文:Markdown 渲染 ——
141
+ `.md` 里所有元素都是 createElement 造出来的(解析器不产生 HTML,渲染器不用 innerHTML),
142
+ 所以这里可以有排版规则,但没有一处「把字符串塞进 DOM」。 */
143
+ .md { white-space: normal; word-break: break-word; line-height: 1.6; }
144
+ .md-p { margin: 0 0 6px; }
145
+ .md-p:last-child, .md-list:last-child, .md-pre:last-child { margin-bottom: 0; }
146
+ .md-h { margin: 10px 0 4px; font-size: 12.5px; color: var(--text); }
147
+ .md-h:first-child { margin-top: 0; }
148
+ .md-list { margin: 0 0 6px; padding-left: 18px; }
149
+ .md-list li { margin: 1px 0; }
150
+ .md-pre {
151
+ position: relative; margin: 0 0 6px; padding: 8px; background: #0e0e10;
152
+ border: 1px solid var(--border); border-radius: 4px; overflow: auto;
153
+ }
154
+ .md-pre code { font-family: var(--mono); font-size: 11.5px; white-space: pre; }
155
+ .md-lang { position: absolute; top: 3px; right: 7px; color: var(--faint); font-size: 9.5px; }
156
+ .md-c { background: #1b1b1f; border-radius: 3px; padding: 0 3px; font-family: var(--mono); }
157
+ .md blockquote { margin: 0 0 6px; padding-left: 9px; border-left: 2px solid var(--border); color: var(--muted); }
158
+ .md a { color: var(--ice); }
159
+ .md hr { border: 0; border-top: 1px solid var(--border); margin: 8px 0; }
160
+ /* 正文用无衬线正文栈(模型输出是散文,等宽栈读它更累);码块与行内码仍是等宽。
161
+ 挂在 `.md-wrap`(渲染器的包装节点)而不是 `#reply .md, .turn .md`:单一类选择器
162
+ 既不用管特异性排序,也让「哪块是模型正文」只由一处决定。 */
163
+ .md-wrap {
164
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
165
+ font-size: 12px;
166
+ }
167
+ /* 折叠态:卡高度 + 底部渐隐(渐隐是「下面还有」的视觉提示;用 mask 所以不影响选中复制) */
168
+ .md-clamped {
169
+ max-height: 216px; overflow: hidden;
170
+ -webkit-mask-image: linear-gradient(180deg, #000 78%, transparent 100%);
171
+ mask-image: linear-gradient(180deg, #000 78%, transparent 100%);
172
+ }
173
+ /* 展开控件:正文**下方**左对齐(长正文里没人会去右上角找一个 10px 的箭头) */
174
+ .md-toggle {
175
+ margin-top: 6px; background: var(--card); border: 1px solid var(--border); border-radius: 4px;
176
+ color: var(--text); padding: 4px 9px; font: inherit; font-size: 11px; cursor: pointer;
177
+ }
178
+ .md-toggle:hover { border-color: var(--ice); }
179
+
109
180
  /* 输入条:固定在底部、横跨两栏、分两行(四个控件挤一行会太窄) */
110
181
  footer {
111
182
  position: sticky; bottom: 0; margin-top: 4px;
@@ -241,11 +312,13 @@
241
312
 
242
313
  <script type="module">
243
314
  import { createTraceView, playTrace, summarizeTrace, renderSummary } from './index.js';
315
+ import { parseMarkdown } from './markdown.js';
244
316
  import {
245
317
  HISTORY_DRAFT,
246
318
  applyTraceEvent,
247
319
  browseTarget,
248
320
  capabilityBadge,
321
+ collapseDecision,
249
322
  emptyTraceAccumulator,
250
323
  historyStep,
251
324
  historyText,
@@ -487,6 +560,123 @@
487
560
  }
488
561
  }
489
562
 
563
+ // ---------- 模型正文的渲染(Markdown + 长了就折叠) ----------
564
+
565
+ /**
566
+ * 展开态**活在渲染之外**(与 trace-view 的 `expanded` 同一个理由):面板每次全量重画,
567
+ * 状态留在 DOM 里就会被下一次重画合上 —— 而「刚点开的那段自己收起来了」是最烦人的那种 bug。
568
+ * 键要**跨重画稳定**:回复用 traceId、对话轮用下标(会话是只追加的,下标稳定)。
569
+ */
570
+ const expandedMsgs = new Set();
571
+
572
+ /** 行内 token → DOM。只走 createElement / textContent —— 见 markdown.ts 的安全前提 */
573
+ function appendInline(parent, inline) {
574
+ for (const n of inline) {
575
+ if (n.type === 'text') {
576
+ parent.appendChild(document.createTextNode(n.text));
577
+ } else if (n.type === 'code') {
578
+ const c = document.createElement('code');
579
+ c.className = 'md-c';
580
+ c.textContent = n.text;
581
+ parent.appendChild(c);
582
+ } else if (n.type === 'link') {
583
+ // href 已在解析层过白名单(isSafeHref);target/rel 防止把这面板当跳板
584
+ const a = document.createElement('a');
585
+ a.href = n.href;
586
+ a.textContent = n.text;
587
+ a.target = '_blank';
588
+ a.rel = 'noopener noreferrer';
589
+ parent.appendChild(a);
590
+ } else {
591
+ const w = document.createElement(n.type === 'strong' ? 'strong' : 'em');
592
+ appendInline(w, n.children);
593
+ parent.appendChild(w);
594
+ }
595
+ }
596
+ }
597
+
598
+ /** Markdown → DOM。块级只认 markdown.ts 的那六种;这里**没有** innerHTML —— 一个都没有 */
599
+ function renderMarkdownInto(el, text) {
600
+ for (const b of parseMarkdown(text)) {
601
+ if (b.type === 'hr') {
602
+ el.appendChild(document.createElement('hr'));
603
+ } else if (b.type === 'code') {
604
+ const pre = document.createElement('pre');
605
+ pre.className = 'md-pre';
606
+ if (b.lang) {
607
+ const tag = document.createElement('span');
608
+ tag.className = 'md-lang';
609
+ tag.textContent = b.lang;
610
+ pre.appendChild(tag);
611
+ }
612
+ const code = document.createElement('code');
613
+ code.textContent = b.text;
614
+ pre.appendChild(code);
615
+ el.appendChild(pre);
616
+ } else if (b.type === 'list') {
617
+ const list = document.createElement(b.ordered ? 'ol' : 'ul');
618
+ list.className = 'md-list';
619
+ for (const item of b.items) {
620
+ const li = document.createElement('li');
621
+ appendInline(li, item);
622
+ list.appendChild(li);
623
+ }
624
+ el.appendChild(list);
625
+ } else if (b.type === 'quote') {
626
+ const q = document.createElement('blockquote');
627
+ appendInline(q, b.inline);
628
+ el.appendChild(q);
629
+ } else {
630
+ const isH = b.type === 'h';
631
+ const node = document.createElement(isH ? `h${Math.min(6, Math.max(1, b.level))}` : 'p');
632
+ node.className = isH ? 'md-h' : 'md-p';
633
+ appendInline(node, b.inline);
634
+ el.appendChild(node);
635
+ }
636
+ }
637
+ }
638
+
639
+ /**
640
+ * 一段**模型正文**(最终回复 / 助手轮)→ 填进 `wrap`。
641
+ *
642
+ * 折叠控件放在正文**下方、左对齐**,不在行尾悬 caret:trace-view 那边 caret 靠右是因为
643
+ * 行是 nowrap 的表格状布局,「右端」本来就是「这一行结束了」的位置;正文块不是那个形态,
644
+ * 一个 10px 的箭头挂在右上角没人会去找。判定走 `collapseDecision`(纯逻辑、带单测)。
645
+ */
646
+ function renderMessage(wrap, text, key) {
647
+ wrap.classList.add('md-wrap');
648
+ const body = document.createElement('div');
649
+ body.className = 'md';
650
+ const d = collapseDecision(text);
651
+ const open = expandedMsgs.has(key);
652
+ if (d.collapsed && !open) body.classList.add('md-clamped');
653
+ renderMarkdownInto(body, text);
654
+ wrap.appendChild(body);
655
+ if (!d.collapsed) return; // 短正文不挂控件(挂一个「展开」是噪声)
656
+ const btn = document.createElement('button');
657
+ btn.type = 'button';
658
+ btn.className = 'md-toggle';
659
+ btn.textContent = open ? '收起 ▴' : `${d.hint} ▾`;
660
+ btn.addEventListener('click', () => {
661
+ if (expandedMsgs.has(key)) expandedMsgs.delete(key);
662
+ else expandedMsgs.add(key);
663
+ renderChat();
664
+ renderReply();
665
+ });
666
+ wrap.appendChild(btn);
667
+ }
668
+
669
+ /** 右栏底部那块回复正文。key 带 traceId ⇒ 换一轮就是一个新的折叠态 */
670
+ function renderReply() {
671
+ replyEl.replaceChildren();
672
+ if (!state.reply) {
673
+ replyEl.hidden = true;
674
+ return;
675
+ }
676
+ replyEl.hidden = false;
677
+ renderMessage(replyEl, state.reply, `reply:${state.replyFor ?? ''}`);
678
+ }
679
+
490
680
  // ---------- 对话流(只在多轮开 + 有会话文件时出现) ----------
491
681
  function renderChat() {
492
682
  const dev = state.dev;
@@ -514,7 +704,10 @@
514
704
  d.textContent = '会话文件还是空的。跑一次多轮 run 就有了。';
515
705
  chatEl.appendChild(d);
516
706
  }
707
+ // 下标当折叠态的键:会话只追加 ⇒ 下标跨重画稳定
708
+ let ti = -1;
517
709
  for (const t of turns) {
710
+ ti++;
518
711
  const box = document.createElement('div');
519
712
  box.className = 'turn' + (t.failed ? ' failed' : t.aborted ? ' aborted' : '');
520
713
  const who = document.createElement('div');
@@ -530,7 +723,10 @@
530
723
  who2.textContent = 'assistant';
531
724
  const a = document.createElement('div');
532
725
  a.className = 'msg';
533
- a.textContent = t.assistant;
726
+ // 助手轮是**模型正文** ⇒ Markdown 渲染 + 长了折叠。
727
+ // 用户轮保持字面(`u.textContent = t.user` 上面那两行没动)—— 那是「我打的字」,
728
+ // 把它按 Markdown 重排会让面板看起来像改过我的输入。
729
+ renderMessage(a, t.assistant, `turn:${ti}`);
534
730
  box.append(who2, a);
535
731
  } else if (t.failed) {
536
732
  // 失败轮**标失败**,不假装那轮没发生(它没回写会话文件,靠 run 列表 join 出来)
@@ -574,8 +770,7 @@
574
770
  state.live = emptyTraceAccumulator();
575
771
  state.reply = '';
576
772
  state.replyFor = null;
577
- replyEl.hidden = true;
578
- replyEl.textContent = '';
773
+ renderReply();
579
774
  summaryEl.replaceChildren();
580
775
  usageEl.hidden = true;
581
776
  ttlEl.textContent = '';
@@ -672,8 +867,7 @@
672
867
  if (!replyBelongsTo(id, state.replyFor)) {
673
868
  state.reply = '';
674
869
  state.replyFor = null;
675
- replyEl.hidden = true;
676
- replyEl.textContent = '';
870
+ renderReply();
677
871
  }
678
872
  renderList();
679
873
  }
@@ -920,8 +1114,8 @@
920
1114
  // 那时不能把回复当「别人的」擦掉(见 panel-logic 的 replyBelongsTo)
921
1115
  state.replyFor = ev.traceId;
922
1116
  state.live = null;
923
- replyEl.hidden = state.reply.length === 0;
924
- replyEl.textContent = state.reply;
1117
+ // Markdown 渲染 + 长了折叠(渲染层不认识正文语义,只按 token 建节点)
1118
+ renderReply();
925
1119
  // 判别顺序(先 stopReason 后 ok)是**语义**,抽在 panel-logic 里并带单测 ——
926
1120
  // 中止的 run `ok` 也是 false(引擎刻意给 aborted 带结构化 error),
927
1121
  // 顺序反了就会把「我按了中止」显示成「run 失败」。
package/dist/inspector.js CHANGED
@@ -226,6 +226,9 @@ const STATIC = new Map([
226
226
  ['summary.js', join(ASSETS, 'summary.js')],
227
227
  ['trace-view.css', join(ASSETS, 'trace-view.css')],
228
228
  ['panel-logic.js', join(HERE, 'panel-logic.js')],
229
+ // 面板的 Markdown 解析器(零依赖、按构造不产生 HTML —— 见 src/markdown.ts 的头注)。
230
+ // 与 panel-logic.js 同处 dist 根:两个都是 CLI 自己编译出来的模块,单测直接 import 它们。
231
+ ['markdown.js', join(HERE, 'markdown.js')],
229
232
  ]);
230
233
  /** 没有 dev 钩子时的状态(面板据此隐藏输入条) */
231
234
  function unavailableDevState() {
@@ -0,0 +1,243 @@
1
+ /**
2
+ * 面板的 **Markdown 最小渲染器** —— 纯逻辑、零 DOM、零依赖。
3
+ *
4
+ * 为什么自己写而不引 `marked` / `markdown-it`:
5
+ * - **零运行时依赖是本 CLI 的硬承诺**(`package.json` 里没有 `dependencies`)。为了面板把一份
6
+ * 解析器 + 一份消毒器(DOMPurify 之类)塞进 dist,等于在 dev 链路里引入两个第三方供应链面,
7
+ * 而它们的产物**没法被本仓的单测验证**(我们只能验「我写的那部分」)。
8
+ * - 面板只需要**模型正文**这一种输入可读:标题 / 粗体 / 斜体 / 行内码 / 围栏码 / 列表 /
9
+ * 引用 / 分隔线 / 链接。这套子集手写不到 200 行,且**每条规则都能配一条单测**。
10
+ * - 最要紧的是**它按构造不产生 HTML**:返回值是一棵 token 树(`MdBlock` / `MdInline`),
11
+ * 没有任何「原样透传的 HTML」这一类。页面的渲染器只准用 `createElement` + `textContent`
12
+ * 把它变成 DOM ⇒ **模型输出里的 `<script>` 只会是字面文本**,不存在「忘了转义」这条路径。
13
+ *
14
+ * ⚠️ 本文件**不许碰 DOM**(不出现 `document` / `window`)—— 它要在 Node 里被直接 import 做单测。
15
+ *
16
+ * 支持的子集(刻意不做的都在「不做」里):
17
+ * - 块:`#` 标题(1–6 级)、围栏码块(``` / ~~~,带语言标注)、无序/有序列表(**一层**)、
18
+ * 引用(合并成一段正文)、`---` 分隔线、段落
19
+ * - 行内:行内码、`**粗**`、`*斜*`、`[文字](链接)`、`<https://…>` 自动链接
20
+ *
21
+ * 不做(都是刻意的,不是漏):
22
+ * - **不做 HTML 透传**(安全前提,见上)
23
+ * - 不做表格 / 任务列表 / 嵌套列表 / 脚注 / 数学 —— 面板是调试视图,不是文档站
24
+ * - 不做 setext 标题(`===` 下划线式)—— 与分隔线歧义大、收益小
25
+ * - 不做图片(`![]()`)—— 面板**不去外网取图**:一次 run 的正文不该触发网络请求
26
+ */
27
+ /**
28
+ * 链接协议白名单。**这是安全边界,不是风格选择**:
29
+ * 模型输出里的 `[点我](javascript:alert(1))` 若被渲染成可点链接,面板就成了 XSS 的跳板
30
+ * (`href` 是唯一一处「我们主动写进属性」的值 —— 文本全走 `textContent` 是安全的,
31
+ * 属性不是)。不合法 ⇒ 这一条**整个降级成字面文本**(连 `[]()` 都照原样显示),
32
+ * 使用者一眼看得出「这里有个链接我没渲染」,而不是看到一个空链接或点不动的链接。
33
+ */
34
+ export function isSafeHref(href) {
35
+ // 允许 http/https/mailto;**不允许** javascript: / data: / vbscript: / file: 等
36
+ return /^(https?:|mailto:)/i.test(href.trim());
37
+ }
38
+ const FENCE = /^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/;
39
+ const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
40
+ const HR = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/;
41
+ const QUOTE = /^\s{0,3}>\s?(.*)$/;
42
+ const UL = /^(\s*)([-*+])\s+(.*)$/;
43
+ const OL = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
44
+ const BLANK = /^\s*$/;
45
+ /**
46
+ * 把一段 Markdown 文本解析成块序列。
47
+ *
48
+ * 三条与「真 Markdown」不同但**刻意**的口径:
49
+ * - 缩进 ≥2 空格的续行归上一条列表项(列表只解析**一层**:更深一层当正文,不递归建树);
50
+ * - 未闭合的围栏码块吃掉到文末(**不静默丢内容** —— 模型被截断时最常见的就是这个形态);
51
+ * - 引用(`>`)合并成**一段正文**(面板里引用几乎总是模型在引日志,逐行建块只会更碎)。
52
+ */
53
+ export function parseMarkdown(text) {
54
+ const src = String(text ?? '').replace(/\r\n?/g, '\n');
55
+ const lines = src.split('\n');
56
+ const blocks = [];
57
+ let i = 0;
58
+ let para = [];
59
+ const flushPara = () => {
60
+ if (para.length === 0)
61
+ return;
62
+ const joined = para.join('\n').trim();
63
+ para = [];
64
+ if (joined)
65
+ blocks.push({ type: 'p', inline: parseInline(joined, 0) });
66
+ };
67
+ while (i < lines.length) {
68
+ const line = lines[i];
69
+ // —— 围栏码块 ——
70
+ const fence = FENCE.exec(line);
71
+ if (fence) {
72
+ flushPara();
73
+ const marker = fence[1][0];
74
+ const min = fence[1].length;
75
+ const lang = fence[2] ?? '';
76
+ const body = [];
77
+ i++;
78
+ while (i < lines.length) {
79
+ const cur = lines[i];
80
+ const close = new RegExp(`^\\s{0,3}${marker === '`' ? '`' : '~'}{${min},}\\s*$`);
81
+ if (close.test(cur)) {
82
+ i++;
83
+ break;
84
+ }
85
+ body.push(cur);
86
+ i++;
87
+ }
88
+ blocks.push({ type: 'code', lang, text: body.join('\n') });
89
+ continue;
90
+ }
91
+ // —— 空行:段落到此为止 ——
92
+ if (BLANK.test(line)) {
93
+ flushPara();
94
+ i++;
95
+ continue;
96
+ }
97
+ // —— 分隔线(必须在列表之前判:`---` 也是无序列表的标记) ——
98
+ if (HR.test(line)) {
99
+ flushPara();
100
+ blocks.push({ type: 'hr' });
101
+ i++;
102
+ continue;
103
+ }
104
+ // —— 标题 ——
105
+ const h = HEADING.exec(line);
106
+ if (h) {
107
+ flushPara();
108
+ blocks.push({
109
+ type: 'h',
110
+ level: h[1].length,
111
+ inline: parseInline(h[2].trim(), 0),
112
+ });
113
+ i++;
114
+ continue;
115
+ }
116
+ // —— 引用:连续 `>` 行合成一段 ——
117
+ if (QUOTE.test(line)) {
118
+ flushPara();
119
+ const quoted = [];
120
+ while (i < lines.length && QUOTE.test(lines[i])) {
121
+ quoted.push(QUOTE.exec(lines[i])[1]);
122
+ i++;
123
+ }
124
+ const joined = quoted.join('\n').trim();
125
+ blocks.push({ type: 'quote', inline: parseInline(joined, 0) });
126
+ continue;
127
+ }
128
+ // —— 列表(无序 / 有序,各收一段连续的) ——
129
+ const ul = UL.exec(line);
130
+ const ol = olAt(line);
131
+ if (ul || ol) {
132
+ flushPara();
133
+ const ordered = ol !== null;
134
+ const items = [];
135
+ while (i < lines.length) {
136
+ const cur = lines[i];
137
+ const m = ordered ? olAt(cur) : UL.exec(cur);
138
+ if (m) {
139
+ items.push(parseInline(m[3].trim(), 0));
140
+ i++;
141
+ continue;
142
+ }
143
+ // 续行:缩进 ≥2 且不是新的一条 ⇒ 并进上一项
144
+ if (items.length > 0 && /^\s{2,}\S/.test(cur) && !BLANK.test(cur)) {
145
+ const last = items[items.length - 1];
146
+ last.push(...parseInline(cur.trim(), 0));
147
+ i++;
148
+ continue;
149
+ }
150
+ break;
151
+ }
152
+ blocks.push({ type: 'list', ordered, items });
153
+ continue;
154
+ }
155
+ // —— 其余:段落(连续非空行合并,行内换行保留) ——
156
+ para.push(line.trim());
157
+ i++;
158
+ }
159
+ flushPara();
160
+ return blocks;
161
+ }
162
+ /** 有序列表标记(`1.` / `1)`),返回与 UL 同形的三组:缩进 / 标记 / 正文 */
163
+ function olAt(line) {
164
+ const m = OL.exec(line);
165
+ return m ? m : null;
166
+ }
167
+ /**
168
+ * 行内解析。`depth` 是**递归上限**:`**a *b* c**` 这类嵌套只展开到第 3 层,
169
+ * 再深就按字面留着 —— 递归无上限时,一条构造出来的正文就能把面板卡死。
170
+ */
171
+ export function parseInline(text, depth) {
172
+ const out = [];
173
+ let buf = '';
174
+ let i = 0;
175
+ const pushText = () => {
176
+ if (buf)
177
+ out.push({ type: 'text', text: buf });
178
+ buf = '';
179
+ };
180
+ while (i < text.length) {
181
+ const c = text[i];
182
+ // 行内码:`...`(不跨行;两端各去掉一个空格 —— 与 CommonMark 同款)
183
+ if (c === '`') {
184
+ const end = text.indexOf('`', i + 1);
185
+ if (end > i + 1) {
186
+ pushText();
187
+ let inner = text.slice(i + 1, end);
188
+ if (inner.startsWith(' ') && inner.endsWith(' ') && inner.trim())
189
+ inner = inner.slice(1, -1);
190
+ out.push({ type: 'code', text: inner });
191
+ i = end + 1;
192
+ continue;
193
+ }
194
+ }
195
+ // 链接:[文字](地址) —— 地址不过白名单就整条降级成字面文本(见 isSafeHref)
196
+ if (c === '[') {
197
+ const m = /^\[([^\]]*)\]\(([^)\s]*)\)/.exec(text.slice(i));
198
+ if (m) {
199
+ const raw = text.slice(i, i + m[0].length);
200
+ const href = m[2].trim();
201
+ if (isSafeHref(href)) {
202
+ pushText();
203
+ out.push({ type: 'link', href, text: m[1].trim() });
204
+ }
205
+ else {
206
+ buf += raw; // 字面保留(连 []() 一起)—— 让使用者看见「这里有个链接我没渲染」
207
+ }
208
+ i += m[0].length;
209
+ continue;
210
+ }
211
+ }
212
+ // 自动链接:<https://…>(裸的 `<` 一律当文本 —— 这正是「不做 HTML 透传」的落点)
213
+ if (c === '<') {
214
+ const m = /^<(https?:\/\/[^\s>]+)>/.exec(text.slice(i));
215
+ if (m) {
216
+ pushText();
217
+ out.push({ type: 'link', href: m[1], text: m[1] });
218
+ i += m[0].length;
219
+ continue;
220
+ }
221
+ }
222
+ // 强调:**粗** / *斜*(`__` / `_` 同义)
223
+ if (c === '*' || c === '_') {
224
+ const strong = text.startsWith(c + c, i);
225
+ const mark = strong ? c + c : c;
226
+ const end = text.indexOf(mark, i + mark.length);
227
+ if (end > i + mark.length) {
228
+ const inner = text.slice(i + mark.length, end);
229
+ if (inner.trim() && !inner.startsWith(' ') && !inner.endsWith(' ')) {
230
+ pushText();
231
+ const children = depth < 3 ? parseInline(inner, depth + 1) : [{ type: 'text', text: inner }];
232
+ out.push(strong ? { type: 'strong', children } : { type: 'em', children });
233
+ i = end + mark.length;
234
+ continue;
235
+ }
236
+ }
237
+ }
238
+ buf += c;
239
+ i++;
240
+ }
241
+ pushText();
242
+ return out;
243
+ }
@@ -381,3 +381,35 @@ export function promptAfterFilePick(current, name) {
381
381
  return { prompt: current, filled: false };
382
382
  return { prompt: name, filled: true };
383
383
  }
384
+ // ---------- ③ 消息折叠:长正文默认收起,短的不加控件 ----------
385
+ /**
386
+ * 超过这么多行就默认折叠。12 行是「一屏大致读得完」的量级 —— 面板右栏在 1223px 下正文行高
387
+ * 约 18px,12 行 ≈ 216px,正好是不把调用树挤出去的上限。
388
+ */
389
+ export const COLLAPSE_MAX_LINES = 12;
390
+ /** 单行长正文的兜底阈值:一行 2000 字符(模型不换行时很常见)同样该收起来 */
391
+ export const COLLAPSE_MAX_CHARS = 1200;
392
+ /**
393
+ * 「这段正文要不要折叠」。**判定是纯逻辑** —— 渲染层只管按结果挂控件,
394
+ * 不自己量高度(量高度依赖布局与字体,单测里量不出来,等于把规则藏进浏览器)。
395
+ *
396
+ * 为什么不按像素量:像素阈值在字体/缩放/窄屏下会漂,同一个回复在不同机器上折叠与否都不一样;
397
+ * 行数 + 字符数是**输入本身的属性**,可复现、可断言。
398
+ *
399
+ * `hint` 是给展开控件用的文案(面板拿它当按钮标题,再加个 caret);不折叠时是空串 ——
400
+ * **短消息上挂一个「展开」是噪声**,所以控件只在真的长时出现。
401
+ */
402
+ export function collapseDecision(text, opts = {}) {
403
+ const t = String(text ?? '');
404
+ const lines = t.length === 0 ? 0 : t.split('\n').length;
405
+ const chars = t.length;
406
+ const maxLines = opts.maxLines ?? COLLAPSE_MAX_LINES;
407
+ const maxChars = opts.maxChars ?? COLLAPSE_MAX_CHARS;
408
+ const collapsed = lines > maxLines || chars > maxChars;
409
+ const hint = collapsed
410
+ ? lines > 1
411
+ ? `展开全文(共 ${lines} 行)`
412
+ : `展开全文(共 ${chars} 字符)`
413
+ : '';
414
+ return { collapsed, lines, chars, hint };
415
+ }
@@ -9,10 +9,10 @@
9
9
  "typecheck": "tsc --noEmit -p tsconfig.json"
10
10
  },
11
11
  "dependencies": {
12
- "@migor/agentia": "^0.9.1"
12
+ "@migor/agentia": "^0.9.2"
13
13
  },
14
14
  "devDependencies": {
15
- "@migor/cli": "^0.9.1",
15
+ "@migor/cli": "^0.9.2",
16
16
  "tsx": "^4.19.0",
17
17
  "typescript": "^7.0.2",
18
18
  "@types/node": "^22.0.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
5
5
  "type": "module",
6
6
  "bin": {