@migor/cli 0.9.1 → 0.9.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.
@@ -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;
@@ -212,6 +283,9 @@
212
283
  </details>
213
284
  <input id="workdir" class="ctl" type="text" spellcheck="false" placeholder="工作目录(绝对路径)" />
214
285
  <button id="browse" class="ctl mini" type="button">浏览…</button>
286
+ <!-- 系统选择…:浏览器弹不出系统目录对话框(拿不到绝对路径),由 dev 进程代弹
287
+ (POST /api/fs/pick)。与「浏览…」(服务端列目录)并列,互为兜底。 -->
288
+ <button id="pick" class="ctl mini" type="button" title="弹出系统目录选择框(在你的桌面上,不在浏览器里)">系统选择…</button>
215
289
  <label class="mt ctl" title="多轮的初始值 = 所选能力声明的 OR;可在这里覆盖">
216
290
  <input type="checkbox" id="mt" /> <span id="mt-label">单轮</span>
217
291
  </label>
@@ -241,12 +315,17 @@
241
315
 
242
316
  <script type="module">
243
317
  import { createTraceView, playTrace, summarizeTrace, renderSummary } from './index.js';
318
+ import { parseMarkdown } from './markdown.js';
244
319
  import {
245
320
  HISTORY_DRAFT,
246
321
  applyTraceEvent,
247
322
  browseTarget,
248
323
  capabilityBadge,
324
+ chatViewVisible,
325
+ collapseDecision,
249
326
  emptyTraceAccumulator,
327
+ filterSelected,
328
+ formatToolSources,
250
329
  historyStep,
251
330
  historyText,
252
331
  mergeConversation,
@@ -260,6 +339,7 @@
260
339
  runDoneNotice,
261
340
  runIsFailure,
262
341
  shortenPath,
342
+ turnKey,
263
343
  } from './panel-logic.js';
264
344
 
265
345
  const $ = (id) => document.getElementById(id);
@@ -365,6 +445,9 @@
365
445
 
366
446
  // 能力多选(默认全选)。全选时 badge 显示 N/N —— 一眼看出这次是全量还是收窄
367
447
  const all = dev.capabilities;
448
+ // runner 重启后菜单可能变了:先丢掉已失效的 token(不丢的话徽标会说谎成「4/3」,
449
+ // 分子里混着菜单上已不存在的名字),再兜空集回落全选
450
+ state.selected = filterSelected(state.selected, all);
368
451
  if (state.selected.length === 0 && all.length > 0) state.selected = [...all];
369
452
  capsSumEl.textContent = `能力 ${capabilityBadge(state.selected, all)} ▾`;
370
453
  capsPopEl.replaceChildren();
@@ -478,7 +561,7 @@
478
561
  if (r.note) {
479
562
  const r3 = document.createElement('span');
480
563
  r3.className = 'r3';
481
- const caps = r.note.toolSources === null ? '全量' : r.note.toolSources.join(',');
564
+ const caps = formatToolSources(r.note.toolSources);
482
565
  r3.textContent = `${short(r.note.workdir)} · ${caps}${r.note.multiTurn ? ' · 多轮' : ''}`;
483
566
  b.appendChild(r3);
484
567
  }
@@ -487,13 +570,133 @@
487
570
  }
488
571
  }
489
572
 
573
+ // ---------- 模型正文的渲染(Markdown + 长了就折叠) ----------
574
+
575
+ /**
576
+ * 展开态**活在渲染之外**(与 trace-view 的 `expanded` 同一个理由):面板每次全量重画,
577
+ * 状态留在 DOM 里就会被下一次重画合上 —— 而「刚点开的那段自己收起来了」是最烦人的那种 bug。
578
+ * 键要**跨重画稳定**:回复用 traceId、对话轮用 `sessionId + 下标`
579
+ * (`turnKey` —— 只追加的会话里下标稳定,而 sessionId 防「清空对话」后
580
+ * 旧下标的展开态套到新对话上)。
581
+ */
582
+ const expandedMsgs = new Set();
583
+
584
+ /** 行内 token → DOM。只走 createElement / textContent —— 见 markdown.ts 的安全前提 */
585
+ function appendInline(parent, inline) {
586
+ for (const n of inline) {
587
+ if (n.type === 'text') {
588
+ parent.appendChild(document.createTextNode(n.text));
589
+ } else if (n.type === 'code') {
590
+ const c = document.createElement('code');
591
+ c.className = 'md-c';
592
+ c.textContent = n.text;
593
+ parent.appendChild(c);
594
+ } else if (n.type === 'link') {
595
+ // href 已在解析层过白名单(isSafeHref);target/rel 防止把这面板当跳板
596
+ const a = document.createElement('a');
597
+ a.href = n.href;
598
+ a.textContent = n.text;
599
+ a.target = '_blank';
600
+ a.rel = 'noopener noreferrer';
601
+ parent.appendChild(a);
602
+ } else {
603
+ const w = document.createElement(n.type === 'strong' ? 'strong' : 'em');
604
+ appendInline(w, n.children);
605
+ parent.appendChild(w);
606
+ }
607
+ }
608
+ }
609
+
610
+ /** Markdown → DOM。块级只认 markdown.ts 的那六种;这里**没有** innerHTML —— 一个都没有 */
611
+ function renderMarkdownInto(el, text) {
612
+ for (const b of parseMarkdown(text)) {
613
+ if (b.type === 'hr') {
614
+ el.appendChild(document.createElement('hr'));
615
+ } else if (b.type === 'code') {
616
+ const pre = document.createElement('pre');
617
+ pre.className = 'md-pre';
618
+ if (b.lang) {
619
+ const tag = document.createElement('span');
620
+ tag.className = 'md-lang';
621
+ tag.textContent = b.lang;
622
+ pre.appendChild(tag);
623
+ }
624
+ const code = document.createElement('code');
625
+ code.textContent = b.text;
626
+ pre.appendChild(code);
627
+ el.appendChild(pre);
628
+ } else if (b.type === 'list') {
629
+ const list = document.createElement(b.ordered ? 'ol' : 'ul');
630
+ list.className = 'md-list';
631
+ for (const item of b.items) {
632
+ const li = document.createElement('li');
633
+ appendInline(li, item);
634
+ list.appendChild(li);
635
+ }
636
+ el.appendChild(list);
637
+ } else if (b.type === 'quote') {
638
+ const q = document.createElement('blockquote');
639
+ appendInline(q, b.inline);
640
+ el.appendChild(q);
641
+ } else {
642
+ const isH = b.type === 'h';
643
+ const node = document.createElement(isH ? `h${Math.min(6, Math.max(1, b.level))}` : 'p');
644
+ node.className = isH ? 'md-h' : 'md-p';
645
+ appendInline(node, b.inline);
646
+ el.appendChild(node);
647
+ }
648
+ }
649
+ }
650
+
651
+ /**
652
+ * 一段**模型正文**(最终回复 / 助手轮)→ 填进 `wrap`。
653
+ *
654
+ * 折叠控件放在正文**下方、左对齐**,不在行尾悬 caret:trace-view 那边 caret 靠右是因为
655
+ * 行是 nowrap 的表格状布局,「右端」本来就是「这一行结束了」的位置;正文块不是那个形态,
656
+ * 一个 10px 的箭头挂在右上角没人会去找。判定走 `collapseDecision`(纯逻辑、带单测)。
657
+ */
658
+ function renderMessage(wrap, text, key) {
659
+ wrap.classList.add('md-wrap');
660
+ const body = document.createElement('div');
661
+ body.className = 'md';
662
+ const d = collapseDecision(text);
663
+ const open = expandedMsgs.has(key);
664
+ if (d.collapsed && !open) body.classList.add('md-clamped');
665
+ renderMarkdownInto(body, text);
666
+ wrap.appendChild(body);
667
+ if (!d.collapsed) return; // 短正文不挂控件(挂一个「展开」是噪声)
668
+ const btn = document.createElement('button');
669
+ btn.type = 'button';
670
+ btn.className = 'md-toggle';
671
+ btn.textContent = open ? '收起 ▴' : `${d.hint} ▾`;
672
+ btn.addEventListener('click', () => {
673
+ if (expandedMsgs.has(key)) expandedMsgs.delete(key);
674
+ else expandedMsgs.add(key);
675
+ renderChat();
676
+ renderReply();
677
+ });
678
+ wrap.appendChild(btn);
679
+ }
680
+
681
+ /** 右栏底部那块回复正文。key 带 traceId ⇒ 换一轮就是一个新的折叠态 */
682
+ function renderReply() {
683
+ replyEl.replaceChildren();
684
+ if (!state.reply) {
685
+ replyEl.hidden = true;
686
+ return;
687
+ }
688
+ replyEl.hidden = false;
689
+ renderMessage(replyEl, state.reply, `reply:${state.replyFor ?? ''}`);
690
+ }
691
+
490
692
  // ---------- 对话流(只在多轮开 + 有会话文件时出现) ----------
491
693
  function renderChat() {
492
694
  const dev = state.dev;
493
695
  const def = multiTurnDefault(state.selected, dev ? dev.multiTurn : []);
494
696
  const value = state.multiTurn ?? def.value;
495
697
  // 三条都要满足才显示:多轮开着、会话文件读得到、dev 环在场 —— 不做空壳
496
- const show = !!dev && dev.available && value && state.session !== null;
698
+ // (判定是纯逻辑 chatViewVisible,这里只做取值 → 调它 → 写回)
699
+ const show = chatViewVisible(dev, value, state.session);
497
700
  tabsEl.hidden = !show;
498
701
  // 清空对话条与对话视图同生共死(它清的就是这份对话)——
499
702
  // **还要跟页签走**:它长在对话视图上,切回 run 列表时不该留在那儿
@@ -514,7 +717,11 @@
514
717
  d.textContent = '会话文件还是空的。跑一次多轮 run 就有了。';
515
718
  chatEl.appendChild(d);
516
719
  }
720
+ // 下标进折叠态的键时必须混进 sessionId(turnKey):「清空对话」换 id 后下标
721
+ // 从 0 重来,不带 id 会把旧会话的展开态套到新对话的第 0/1/… 轮上
722
+ let ti = -1;
517
723
  for (const t of turns) {
724
+ ti++;
518
725
  const box = document.createElement('div');
519
726
  box.className = 'turn' + (t.failed ? ' failed' : t.aborted ? ' aborted' : '');
520
727
  const who = document.createElement('div');
@@ -530,7 +737,10 @@
530
737
  who2.textContent = 'assistant';
531
738
  const a = document.createElement('div');
532
739
  a.className = 'msg';
533
- a.textContent = t.assistant;
740
+ // 助手轮是**模型正文** ⇒ Markdown 渲染 + 长了折叠。
741
+ // 用户轮保持字面(`u.textContent = t.user` 上面那两行没动)—— 那是「我打的字」,
742
+ // 把它按 Markdown 重排会让面板看起来像改过我的输入。
743
+ renderMessage(a, t.assistant, turnKey(state.sessionId, ti));
534
744
  box.append(who2, a);
535
745
  } else if (t.failed) {
536
746
  // 失败轮**标失败**,不假装那轮没发生(它没回写会话文件,靠 run 列表 join 出来)
@@ -574,8 +784,7 @@
574
784
  state.live = emptyTraceAccumulator();
575
785
  state.reply = '';
576
786
  state.replyFor = null;
577
- replyEl.hidden = true;
578
- replyEl.textContent = '';
787
+ renderReply();
579
788
  summaryEl.replaceChildren();
580
789
  usageEl.hidden = true;
581
790
  ttlEl.textContent = '';
@@ -609,11 +818,21 @@
609
818
 
610
819
  // ---------- 拉数据 ----------
611
820
  async function refreshDev() {
821
+ // ⚠️ 与 refresh() 同一口径:必须查 res.ok。403(token 失效 / cookie 过期)时
822
+ // 旧实现静默把 dev 置空 ⇒ 输入条无声消失;服务端 403 的 error 文案本来就写清了
823
+ // 解决办法,把它亮出来,别让用户对着「输入条怎么没了」干瞪眼。
612
824
  try {
613
825
  const res = await fetch('/api/dev');
614
- state.dev = res.ok ? await res.json() : null;
615
- } catch {
826
+ if (!res.ok) {
827
+ const body = await res.json().catch(() => ({}));
828
+ state.dev = null;
829
+ setNotice(`读 dev 状态失败:${body.error || res.status}(输入条因此先收起来了)`, true);
830
+ } else {
831
+ state.dev = await res.json();
832
+ }
833
+ } catch (e) {
616
834
  state.dev = null;
835
+ setNotice(`读 dev 状态失败:${e.message}(输入条因此先收起来了)`, true);
617
836
  }
618
837
  // 会话 id 由 dev 环持有(清空对话会换它)—— 面板不自己造,只跟着走
619
838
  state.sessionId = state.dev?.sessionId ?? '';
@@ -627,12 +846,21 @@
627
846
 
628
847
  async function refreshSession() {
629
848
  if (!state.dev?.available) return;
849
+ // ⚠️ 与 refresh() 同一口径:失败必须亮出来。旧实现静默把 session 置空 ⇒
850
+ // 对话无声变空(看起来像「对话被清了」,其实只是 403 / 读文件失败)
630
851
  try {
631
852
  const res = await fetch('/api/session');
632
- const body = res.ok ? await res.json() : { session: null };
853
+ if (!res.ok) {
854
+ const body = await res.json().catch(() => ({}));
855
+ state.session = null;
856
+ setNotice(`读会话失败:${body.error || res.status}(对话视图因此先收起来了)`, true);
857
+ return;
858
+ }
859
+ const body = await res.json();
633
860
  state.session = body.session ?? null;
634
- } catch {
861
+ } catch (e) {
635
862
  state.session = null;
863
+ setNotice(`读会话失败:${e.message}(对话视图因此先收起来了)`, true);
636
864
  }
637
865
  }
638
866
 
@@ -672,8 +900,7 @@
672
900
  if (!replyBelongsTo(id, state.replyFor)) {
673
901
  state.reply = '';
674
902
  state.replyFor = null;
675
- replyEl.hidden = true;
676
- replyEl.textContent = '';
903
+ renderReply();
677
904
  }
678
905
  renderList();
679
906
  }
@@ -771,6 +998,57 @@
771
998
  void openFs(browseTarget(workdirEl.value, state.dev ? state.dev.defaultWorkdir : '.'));
772
999
  });
773
1000
 
1001
+ // ---------- 系统目录选择器(与「浏览…」并列,互为兜底) ----------
1002
+ // 浏览器弹不出系统对话框 ⇒ 由 dev 进程代弹(POST /api/fs/pick;token 走 cookie,
1003
+ // 与其它请求同一条缝)。服务端契约:
1004
+ // 200 {path} 选了 / 200 {path:null} 取消 / 409 已有一个在等 / 501 平台没有可用选择器
1005
+ // 等待期间按钮置灰防重点(第一道闸;服务端那条 409 是第二道)。
1006
+ // 任何一个失败分支都必须有用户可见的提示 —— 不许静默。
1007
+ const pickEl = $('pick');
1008
+ pickEl.addEventListener('click', async () => {
1009
+ pickEl.disabled = true;
1010
+ setNotice('系统选择器已打开…(在你的桌面上,不在浏览器里)', false);
1011
+ try {
1012
+ const res = await fetch('/api/fs/pick', { method: 'POST' });
1013
+ const body = await res.json().catch(() => ({}));
1014
+ if (res.status === 501) {
1015
+ setNotice(`系统选择器不可用:${body.error || '当前平台没有可用的系统选择器'} —— 请改用旁边的「浏览…」`, true);
1016
+ return;
1017
+ }
1018
+ if (!res.ok) {
1019
+ // 409 与其余失败都走这里:服务端的 error 文案说明原因
1020
+ setNotice(`系统选择器打开失败:${body.error || res.status}`, true);
1021
+ return;
1022
+ }
1023
+ if (body.path === null || body.path === undefined) {
1024
+ setNotice('已取消(没选目录)', false);
1025
+ return;
1026
+ }
1027
+ workdirEl.value = body.path;
1028
+ state.workdir = body.path;
1029
+ setNotice(`工作目录:${short(body.path)}`, false);
1030
+ } catch (e) {
1031
+ setNotice(`系统选择器请求失败:${e.message}`, true);
1032
+ } finally {
1033
+ pickEl.disabled = false;
1034
+ }
1035
+ });
1036
+
1037
+ // 页面卸载(刷新 / 关标签页 / 跳走)⇒ **显式**告诉服务端「在飞的选择框没人看了」。
1038
+ // 为什么不能只靠连接断开:实测(2026-09-23,带插桩的产物 + 真浏览器)刷新后那条连接
1039
+ // **还开着** ⇒ 服务端收不到 close ⇒ 选择框留在桌面上、之后每次点都 409。
1040
+ // beacon 是**一次新的请求**,与服务端看不看得见断开无关;token 走 cookie(同一道缝)。
1041
+ // 无条件发:服务端幂等(没有在飞的 pick 就是空操作),省掉一份会漂的页面态。
1042
+ // ⚠️ 路径字面量与 `inspector-routes.ts` 的 PICK_CANCEL_PATH 必须逐字一致 ——
1043
+ // inspector.test.mjs 有逐字对拍断言钉着(页面不能 import 那个常量)。
1044
+ window.addEventListener('pagehide', () => {
1045
+ try {
1046
+ navigator.sendBeacon('/api/fs/pick/cancel');
1047
+ } catch {
1048
+ /* 老浏览器 / 被策略拦下:还有 res close 那条路兜着,不阻断卸载 */
1049
+ }
1050
+ });
1051
+
774
1052
  // ---------- 控件接线(只做「取值 → 调 panel-logic → 写回」) ----------
775
1053
  workdirEl.addEventListener('input', () => {
776
1054
  state.workdir = workdirEl.value;
@@ -904,7 +1182,7 @@
904
1182
  // 右栏从这一轮的空树开始(① 实时):不这么做的话它留着上一轮那棵树
905
1183
  beginLiveRender();
906
1184
  setNotice(
907
- `运行中:${short(ev.workdir)} · ${ev.toolSources === null ? '全量' : ev.toolSources.join(',')}` +
1185
+ `运行中:${short(ev.workdir)} · ${formatToolSources(ev.toolSources)}` +
908
1186
  `${ev.multiTurn ? ' · 多轮' : ''}`,
909
1187
  false,
910
1188
  );
@@ -920,8 +1198,8 @@
920
1198
  // 那时不能把回复当「别人的」擦掉(见 panel-logic 的 replyBelongsTo)
921
1199
  state.replyFor = ev.traceId;
922
1200
  state.live = null;
923
- replyEl.hidden = state.reply.length === 0;
924
- replyEl.textContent = state.reply;
1201
+ // Markdown 渲染 + 长了折叠(渲染层不认识正文语义,只按 token 建节点)
1202
+ renderReply();
925
1203
  // 判别顺序(先 stopReason 后 ok)是**语义**,抽在 panel-logic 里并带单测 ——
926
1204
  // 中止的 run `ok` 也是 false(引擎刻意给 aborted 带结构化 error),
927
1205
  // 顺序反了就会把「我按了中止」显示成「run 失败」。