@migor/cli 0.9.0 → 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.
@@ -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;
@@ -151,6 +222,11 @@
151
222
  .fs-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); }
152
223
  .fs-item { display: block; width: 100%; text-align: left; background: none; border: 0; color: var(--text); padding: 4px 6px; border-radius: 4px; font: inherit; font-family: var(--mono); font-size: 12px; cursor: pointer; }
153
224
  .fs-item:hover { background: #202024; }
225
+ /* 隐藏目录:可达但不可眼(旧实现直接过滤掉,于是 .y 这类目录根本点不进去) */
226
+ .fs-dot { color: var(--faint); }
227
+ /* 文件行:点它 = 选它所在的目录(不是选文件本身当工作目录 —— 工作目录只能是目录) */
228
+ .fs-file { color: var(--text); }
229
+ .fs-sep { padding: 8px 6px 2px; margin-top: 6px; border-top: 1px solid var(--border); font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
154
230
  .fs-foot { padding-top: 8px; border-top: 1px solid var(--border); margin-top: 6px; }
155
231
  .mini { background: var(--card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); padding: 5px 9px; font: inherit; font-size: 11.5px; cursor: pointer; }
156
232
  .mini:hover { border-color: var(--ice); }
@@ -227,22 +303,33 @@
227
303
  <span id="fs-path"></span>
228
304
  </div>
229
305
  <div id="fs-list"></div>
230
- <div class="fs-foot"><button class="mini" id="fs-use" type="button">选这个目录</button></div>
306
+ <div class="fs-foot">
307
+ <button class="mini" id="fs-use" type="button">选这个目录</button>
308
+ <span class="hint"> 点文件名 = 选它所在的目录(prompt 为空时填文件名)</span>
309
+ </div>
231
310
  </div>
232
311
  </footer>
233
312
 
234
313
  <script type="module">
235
314
  import { createTraceView, playTrace, summarizeTrace, renderSummary } from './index.js';
315
+ import { parseMarkdown } from './markdown.js';
236
316
  import {
237
317
  HISTORY_DRAFT,
318
+ applyTraceEvent,
319
+ browseTarget,
238
320
  capabilityBadge,
321
+ collapseDecision,
322
+ emptyTraceAccumulator,
239
323
  historyStep,
240
324
  historyText,
241
325
  mergeConversation,
242
326
  multiTurnDefault,
243
327
  multiTurnLabel,
244
328
  normalizeToolSources,
329
+ partialTrace,
330
+ promptAfterFilePick,
245
331
  pushPromptHistory,
332
+ replyBelongsTo,
246
333
  runDoneNotice,
247
334
  runIsFailure,
248
335
  shortenPath,
@@ -298,6 +385,17 @@
298
385
  list: [],
299
386
  current: null,
300
387
  reply: '',
388
+ /**
389
+ * 这段回复是**哪个 traceId** 的(`run-done` 带回来的)。`open()` 靠它判断
390
+ * 「现在打开的就是这条回复的主人」⇒ 不清。见 panel-logic 的 `replyBelongsTo`。
391
+ */
392
+ replyFor: null,
393
+ /**
394
+ * 在飞 run 的增量 trace 累加器(null = 没有在飞的 run)。
395
+ * 收到 `trace-event` 就折回并重画;收尾那份整棵 trace 回来时把它清掉
396
+ * (**收尾那份永远覆盖在飞的这份**,缺的 span 由它补齐)。
397
+ */
398
+ live: null,
301
399
  notice: '', // 面板自己发的操作反馈(已接受 / 重启了 runner / 出错)
302
400
  noticeErr: false,
303
401
  };
@@ -462,6 +560,123 @@
462
560
  }
463
561
  }
464
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
+
465
680
  // ---------- 对话流(只在多轮开 + 有会话文件时出现) ----------
466
681
  function renderChat() {
467
682
  const dev = state.dev;
@@ -489,7 +704,10 @@
489
704
  d.textContent = '会话文件还是空的。跑一次多轮 run 就有了。';
490
705
  chatEl.appendChild(d);
491
706
  }
707
+ // 下标当折叠态的键:会话只追加 ⇒ 下标跨重画稳定
708
+ let ti = -1;
492
709
  for (const t of turns) {
710
+ ti++;
493
711
  const box = document.createElement('div');
494
712
  box.className = 'turn' + (t.failed ? ' failed' : t.aborted ? ' aborted' : '');
495
713
  const who = document.createElement('div');
@@ -505,7 +723,10 @@
505
723
  who2.textContent = 'assistant';
506
724
  const a = document.createElement('div');
507
725
  a.className = 'msg';
508
- a.textContent = t.assistant;
726
+ // 助手轮是**模型正文** ⇒ Markdown 渲染 + 长了折叠。
727
+ // 用户轮保持字面(`u.textContent = t.user` 上面那两行没动)—— 那是「我打的字」,
728
+ // 把它按 Markdown 重排会让面板看起来像改过我的输入。
729
+ renderMessage(a, t.assistant, `turn:${ti}`);
509
730
  box.append(who2, a);
510
731
  } else if (t.failed) {
511
732
  // 失败轮**标失败**,不假装那轮没发生(它没回写会话文件,靠 run 列表 join 出来)
@@ -536,6 +757,51 @@
536
757
  renderChat();
537
758
  }
538
759
 
760
+ // ---------- 在飞 run 的实时右栏(①) ----------
761
+
762
+ /**
763
+ * run 开跑:把右栏切成「这一轮的空树」。
764
+ *
765
+ * 为什么必须在 run-start 就切(而不是等第一条 span):不切的话右栏留着**上一轮**那棵树,
766
+ * 一次十几秒的 run 期间它看起来就是「这次的结果」。同理清掉上一轮的回复 / 排行 / 用量 ——
767
+ * 它们都是**上一轮**的账,与在飞的这一轮无关(历史 run 在左栏列表里随时能点回来)。
768
+ */
769
+ function beginLiveRender() {
770
+ state.live = emptyTraceAccumulator();
771
+ state.reply = '';
772
+ state.replyFor = null;
773
+ renderReply();
774
+ summaryEl.replaceChildren();
775
+ usageEl.hidden = true;
776
+ ttlEl.textContent = '';
777
+ const ph = document.createElement('div');
778
+ ph.className = 'tr-empty';
779
+ ph.textContent = '// 这一轮在飞 —— span 会一个个长出来(收尾时用整棵 trace 覆盖)';
780
+ traceEl.replaceChildren(ph);
781
+ }
782
+
783
+ /**
784
+ * 增量事件 → 折回 → 重画整棵树。
785
+ *
786
+ * `playTrace` 是**幂等全量重画**(自己拆好树再画),所以这里不需要「局部更新」那套:
787
+ * 每次把当前已知的 spans 交回去就行。用 rAF 合帧是必要的 —— 一次 run 可能有几百条
788
+ * 记账事件,逐条重绘是白烧 CPU(而人眼只看得见一帧)。
789
+ */
790
+ let liveFrame = 0;
791
+ function renderLive() {
792
+ if (liveFrame) return;
793
+ liveFrame = requestAnimationFrame(() => {
794
+ liveFrame = 0;
795
+ if (!state.live) return;
796
+ const t = partialTrace(state.live);
797
+ // 空树不交给 playTrace(它对空 spans 直接返回 false,右栏会留着占位那行 —— 那正是我们要的)
798
+ if (t.spans.length === 0) return;
799
+ if (!playTrace(view, t)) return;
800
+ ttlEl.textContent = `${t.traceId}(在飞)`;
801
+ usageEl.hidden = false;
802
+ });
803
+ }
804
+
539
805
  // ---------- 拉数据 ----------
540
806
  async function refreshDev() {
541
807
  try {
@@ -546,6 +812,9 @@
546
812
  }
547
813
  // 会话 id 由 dev 环持有(清空对话会换它)—— 面板不自己造,只跟着走
548
814
  state.sessionId = state.dev?.sessionId ?? '';
815
+ // 页面**在 run 在飞时**才加载(刷新 / 新开标签)⇒ 补一个累加器,好接住后续的增量帧。
816
+ // 之前那些 span 收不到(SSE 不重放)—— 它们会随收尾那份整棵 trace 一起补齐。
817
+ if (state.dev?.running && !state.live) state.live = emptyTraceAccumulator();
549
818
  renderControls();
550
819
  renderChat();
551
820
  renderBanner();
@@ -590,10 +859,16 @@
590
859
  playTrace(view, trace);
591
860
  summaryEl.innerHTML = renderSummary(summarizeTrace(trace));
592
861
  // 回复正文只对**刚跑完的那一轮**有意义(trace 上没有它 —— 它是 run-done 事件带的)
593
- // ⇒ 打开历史 run 必须清掉,否则它挂在别人的 trace 底下(张冠李戴)
594
- state.reply = '';
595
- replyEl.hidden = true;
596
- replyEl.textContent = '';
862
+ // ⇒ 打开**别的** run 时必须清掉,否则它挂在别人的 trace 底下(张冠李戴)。
863
+ //
864
+ // ⚠️ 但不能**无条件**清:`run-done` 与「这条 trace 的收尾帧」几乎同毫秒到达,
865
+ // 而这条 handler 的链更长(refresh → refreshSession → renderChat → open)——
866
+ // 无条件清会把刚写上去的那句回复擦掉(端到端实测 17 ms,现象是「跑完了但没回复」)。
867
+ if (!replyBelongsTo(id, state.replyFor)) {
868
+ state.reply = '';
869
+ state.replyFor = null;
870
+ renderReply();
871
+ }
597
872
  renderList();
598
873
  }
599
874
 
@@ -602,10 +877,20 @@
602
877
  if (state.list.length && !state.current) await open(state.list[0].traceId);
603
878
  }
604
879
 
605
- // ---------- 目录浏览 ----------
880
+ // ---------- 目录浏览 / 选文件(③) ----------
606
881
  // 浏览器拿不到用户选的文件夹的绝对路径(webkitdirectory 只给相对路径、
607
882
  // File System Access 只给 handle.name),而 dev 环要的是绝对路径 ⇒ 服务端列目录。
883
+ //
884
+ // 三个控件语义(2026-09-22 复核后定,前两条是实测出来的缺口):
885
+ // - 点**目录名** ⇒ 进去;点 `..` ⇒ 上一层;「选这个目录」⇒ 把当前位置当工作目录。
886
+ // - 点**文件名** ⇒ 工作目录 = 它**所在的**目录(就是当前这一层),prompt 为空时填文件名。
887
+ // 只列目录的旧形态下,「让 agent 看某个文件」只能靠用户自己把名字打进 prompt。
888
+ // - 隐藏目录(`.git` / `~/x/.y`)单列在正常目录之后 —— 旧实现整批过滤掉,
889
+ // 于是那些目录**根本点不进去**(`..` 只能退到它上面,进不去)。
608
890
  let fsPath = '';
891
+ function closeFs() {
892
+ fsEl.hidden = true;
893
+ }
609
894
  async function openFs(at) {
610
895
  const res = await fetch('/api/fs?path=' + encodeURIComponent(at));
611
896
  if (!res.ok) {
@@ -617,30 +902,67 @@
617
902
  fsPath = body.path;
618
903
  fsPathEl.textContent = body.path;
619
904
  fsListEl.replaceChildren();
620
- for (const name of body.dirs) {
905
+
906
+ const row = (text, cls, title, onClick) => {
621
907
  const b = document.createElement('button');
622
908
  b.type = 'button';
623
- b.className = 'fs-item';
624
- b.textContent = name + '/';
625
- b.addEventListener('click', () => openFs(fsPath + '/' + name));
909
+ b.className = cls;
910
+ b.textContent = text;
911
+ if (title) b.title = title;
912
+ b.addEventListener('click', onClick);
626
913
  fsListEl.appendChild(b);
914
+ };
915
+ const enterDir = (name) => () => void openFs(`${fsPath}/${name}`);
916
+ /** 选一个文件:工作目录取**当前这一层**(它所在的目录),prompt 只在空时被填 */
917
+ const useFile = (name) => () => {
918
+ workdirEl.value = fsPath;
919
+ state.workdir = fsPath;
920
+ const picked = promptAfterFilePick(promptEl.value, name);
921
+ if (picked.filled) promptEl.value = picked.prompt;
922
+ closeFs();
923
+ setNotice(
924
+ picked.filled
925
+ ? `工作目录:${short(fsPath)}(prompt 已填「${name}」)`
926
+ : `工作目录:${short(fsPath)}(prompt 里已有内容,一个字没动)`,
927
+ false,
928
+ );
929
+ };
930
+
931
+ for (const name of body.dirs) row(name + '/', 'fs-item', '', enterDir(name));
932
+ for (const name of body.dotDirs ?? []) {
933
+ row(name + '/', 'fs-item fs-dot', '隐藏目录', enterDir(name));
934
+ }
935
+ const files = body.files ?? [];
936
+ if (files.length > 0) {
937
+ const sep = document.createElement('div');
938
+ sep.className = 'fs-sep';
939
+ sep.textContent = '文件';
940
+ fsListEl.appendChild(sep);
941
+ for (const name of files) {
942
+ row(name, 'fs-item fs-file', '选这个文件:工作目录设成它所在的目录,prompt 为空时填文件名', useFile(name));
943
+ }
944
+ }
945
+ if (body.filesTruncated) {
946
+ const more = document.createElement('div');
947
+ more.className = 'hint';
948
+ more.textContent = `(文件只列了前 ${files.length} 个)`;
949
+ fsListEl.appendChild(more);
627
950
  }
628
951
  fsEl.hidden = false;
629
952
  fsEl.dataset.parent = body.parent;
630
953
  }
631
- $('fs-up').addEventListener('click', () => openFs(fsEl.dataset.parent || fsPath));
954
+ $('fs-up').addEventListener('click', () => void openFs(fsEl.dataset.parent || fsPath));
632
955
  $('fs-use').addEventListener('click', () => {
633
956
  workdirEl.value = fsPath;
634
957
  state.workdir = fsPath;
635
- fsEl.hidden = true;
958
+ closeFs();
636
959
  setNotice(`工作目录:${short(fsPath)}`, false);
637
960
  });
961
+ // ⚠️ 这里**不是**开关:旧实现「面板开着时再点一次把它关掉」,于是
962
+ // 「在输入框敲了目标路径 → 点浏览…」这个最自然的动作是把面板关掉,
963
+ // 用户就以为「选不了别的目录」。现在点它**总是**按当前输入打开。
638
964
  $('browse').addEventListener('click', () => {
639
- if (!fsEl.hidden) {
640
- fsEl.hidden = true;
641
- return;
642
- }
643
- void openFs(workdirEl.value.trim() || (state.dev ? state.dev.defaultWorkdir : '.'));
965
+ void openFs(browseTarget(workdirEl.value, state.dev ? state.dev.defaultWorkdir : '.'));
644
966
  });
645
967
 
646
968
  // ---------- 控件接线(只做「取值 → 调 panel-logic → 写回」) ----------
@@ -654,6 +976,9 @@
654
976
  });
655
977
  document.addEventListener('click', (e) => {
656
978
  if (capsEl.open && !capsEl.contains(e.target)) capsEl.open = false;
979
+ // 目录浏览器:点面板外面收起。`浏览…` 不再是开关之后,这是主要的收起方式
980
+ // (按钮自己那一支要排除,否则点它就会「开完立刻被这里收掉」)。
981
+ if (!fsEl.hidden && !fsEl.contains(e.target) && !$('browse').contains(e.target)) closeFs();
657
982
  });
658
983
 
659
984
  promptEl.addEventListener('keydown', (e) => {
@@ -758,6 +1083,9 @@
758
1083
  const nameEl = document.createElement('b');
759
1084
  nameEl.textContent = s.name;
760
1085
  liveEl.append(nameEl, ` · ${fmtMs(s.ms)}`);
1086
+ // 收尾那份整棵 trace 到了 ⇒ 在飞的那份**作废**:`open()` 画的是权威版本,
1087
+ // 而且此后不该再让迟到的增量帧把它重画成残缺的一棵(见 dev-protocol 的 DevEvent)
1088
+ state.live = null;
761
1089
  await refresh();
762
1090
  await refreshSession();
763
1091
  renderChat();
@@ -767,6 +1095,8 @@
767
1095
  const ev = JSON.parse(m.data);
768
1096
  if (ev.kind === 'run-start') {
769
1097
  liveEl.textContent = '正在跑…';
1098
+ // 右栏从这一轮的空树开始(① 实时):不这么做的话它留着上一轮那棵树
1099
+ beginLiveRender();
770
1100
  setNotice(
771
1101
  `运行中:${short(ev.workdir)} · ${ev.toolSources === null ? '全量' : ev.toolSources.join(',')}` +
772
1102
  `${ev.multiTurn ? ' · 多轮' : ''}`,
@@ -774,10 +1104,18 @@
774
1104
  );
775
1105
  // run-start 是「确实开跑了」的权威信号 ⇒ 中止按钮在这里出现(不看面板自己的乐观状态)
776
1106
  await refreshDev();
1107
+ } else if (ev.kind === 'trace-event') {
1108
+ // 在飞的一笔记账(①):折回 + 合帧重画。`state.live` 为空说明收尾那份已经到了
1109
+ // (或这一轮不是面板起的)⇒ 丢掉 —— 权威版本已经在屏幕上,别用残缺的覆盖它。
1110
+ if (state.live && applyTraceEvent(state.live, ev.event)) renderLive();
777
1111
  } else if (ev.kind === 'run-done') {
778
1112
  state.reply = ev.finalText || '';
779
- replyEl.hidden = state.reply.length === 0;
780
- replyEl.textContent = state.reply;
1113
+ // 记下这条回复的**主人**:紧随其后的收尾帧会 open() 同一条 traceId,
1114
+ // 那时不能把回复当「别人的」擦掉(见 panel-logic 的 replyBelongsTo)
1115
+ state.replyFor = ev.traceId;
1116
+ state.live = null;
1117
+ // Markdown 渲染 + 长了折叠(渲染层不认识正文语义,只按 token 建节点)
1118
+ renderReply();
781
1119
  // 判别顺序(先 stopReason 后 ok)是**语义**,抽在 panel-logic 里并带单测 ——
782
1120
  // 中止的 run `ok` 也是 false(引擎刻意给 aborted 带结构化 error),
783
1121
  // 顺序反了就会把「我按了中止」显示成「run 失败」。
@@ -789,6 +1127,8 @@
789
1127
  renderChat();
790
1128
  } else if (ev.kind === 'runner-restart') {
791
1129
  setNotice(`重启 runner:${ev.reason}`, false);
1130
+ // 重启前的在飞 run 已经不存在了(它的 trace 会丢,面板在别处明说)⇒ 别留一棵半截树
1131
+ state.live = null;
792
1132
  // 重启期间菜单是过期的(旧 runner 的能力)—— 先刷一次,等 runner-ready 再刷一次。
793
1133
  await refreshDev();
794
1134
  } else if (ev.kind === 'runner-ready') {
@@ -797,6 +1137,7 @@
797
1137
  await refreshDev();
798
1138
  } else if (ev.kind === 'runner-error') {
799
1139
  setNotice(ev.message, true);
1140
+ state.live = null;
800
1141
  await refreshDev();
801
1142
  }
802
1143
  });
@@ -15,3 +15,22 @@ export function createInspectSink(opts) {
15
15
  },
16
16
  };
17
17
  }
18
+ export function createInspectEventSink(opts) {
19
+ const url = `http://${opts.host ?? '127.0.0.1'}:${opts.port}/ingest-event`;
20
+ const headers = { 'content-type': 'application/json' };
21
+ if (opts.token)
22
+ headers['x-agentia-token'] = opts.token;
23
+ let chain = Promise.resolve();
24
+ return {
25
+ send(event) {
26
+ chain = chain.then(async () => {
27
+ try {
28
+ await fetch(url, { method: 'POST', headers, body: JSON.stringify(event) });
29
+ }
30
+ catch {
31
+ /* 面板未开 / 已关 / 连接被拒:静默(观测失败不击穿 dev) */
32
+ }
33
+ });
34
+ },
35
+ };
36
+ }