@zhushanwen/pi-subagent-workflow 8.8.0 → 8.9.0

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.
Files changed (38) hide show
  1. package/package.json +9 -8
  2. package/skills/subagent-ext-config/SKILL.md +21 -1
  3. package/src/host/pi-host.ts +5 -4
  4. package/src/index.ts +145 -464
  5. package/src/injectors/__tests__/engine-awareness.test.ts +4 -2
  6. package/src/injectors/__tests__/engine-section-stability.test.ts +42 -29
  7. package/src/injectors/engine-awareness.ts +80 -4
  8. package/src/injectors/resource-list-injector.ts +209 -0
  9. package/src/injectors/subagent-list-injector.ts +30 -143
  10. package/src/injectors/workflow-list-injector.ts +23 -150
  11. package/src/interface/__tests__/detectors.test.ts +1 -1
  12. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +42 -26
  13. package/src/interface/__tests__/subagent-tool-prompt.test.ts +15 -6
  14. package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +1 -1
  15. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +1 -1
  16. package/src/interface/bg-notify-render.ts +4 -4
  17. package/src/interface/commands.ts +115 -75
  18. package/src/interface/format.ts +339 -241
  19. package/src/interface/helpers.ts +2 -2
  20. package/src/interface/list-component.ts +163 -116
  21. package/src/interface/list-shared.ts +2 -2
  22. package/src/interface/list-view.ts +129 -72
  23. package/src/interface/subagent-actions.ts +5 -5
  24. package/src/interface/subagent-tool-schema.ts +21 -6
  25. package/src/interface/subagent-tool.ts +31 -29
  26. package/src/interface/subagents.ts +8 -7
  27. package/src/interface/tool-render.ts +2 -2
  28. package/src/interface/tool-workflow-script.ts +15 -14
  29. package/src/interface/tool-workflow.ts +14 -11
  30. package/src/interface/tui-kit.ts +305 -0
  31. package/src/interface/views/WorkflowsView.ts +267 -196
  32. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +3 -3
  33. package/src/interface/views/__tests__/detail-content-session-file.test.ts +7 -4
  34. package/src/interface/views/detail-content.ts +16 -14
  35. package/src/interface/views/view-constants.ts +19 -0
  36. package/src/jsonl-run-store.ts +11 -7
  37. package/src/session-lifecycle.ts +561 -0
  38. package/src/interface/views/format.ts +0 -281
@@ -27,20 +27,18 @@ import { join as pathJoin } from "node:path";
27
27
 
28
28
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
29
29
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
30
- import { Key, matchesKey } from "@earendil-works/pi-tui";
30
+ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
31
31
 
32
32
  import {
33
33
  countAllToolCalls,
34
34
  getAllToolCalls,
35
35
  projectLiveProgress,
36
- } from "@zhushanwen/subagent-core/execution/execution-record.ts";
37
- import type { ExecutionTraceNode } from "@zhushanwen/subagent-core/orchestration/models/types.ts";
38
- import type { WorkflowRun } from "@zhushanwen/subagent-core/orchestration/models/workflow-run.ts";
39
- import { saveWorkflow } from "@zhushanwen/subagent-core/orchestration/workflow-files.ts";
40
- import { displayAgentName } from "@zhushanwen/subagent-core/shared/agent-ref.ts";
36
+ } from "@zhushanwen/subagent-core";
37
+ import type { ExecutionTraceNode } from "@zhushanwen/subagent-core";
38
+ import type { WorkflowRun } from "@zhushanwen/subagent-core";
39
+ import { saveWorkflow } from "@zhushanwen/subagent-core";
40
+ import { displayAgentName } from "@zhushanwen/subagent-core";
41
41
  import {
42
- BOX_BORDER_CHARS,
43
- BUDGET_TOKENS_DIVISOR,
44
42
  buildPhaseGroups,
45
43
  ELLIPSIS,
46
44
  formatActivityLine,
@@ -49,13 +47,22 @@ import {
49
47
  formatElapsedSeconds,
50
48
  formatPhaseLine,
51
49
  formatStatusBadge,
52
- padVisible,
53
- SIDEBAR_WIDTH,
54
50
  statusDotStr,
55
- TERM_ROWS_FALLBACK,
56
51
  type ThemeLike,
57
- visibleLen,
58
- } from "./format.ts";
52
+ } from "../format.ts";
53
+ import {
54
+ b,
55
+ dashes,
56
+ padToVisible,
57
+ plainBorder,
58
+ termRows,
59
+ walled,
60
+ } from "../tui-kit.ts";
61
+ import {
62
+ BOX_BORDER_CHARS,
63
+ BUDGET_TOKENS_DIVISOR,
64
+ SIDEBAR_WIDTH,
65
+ } from "./view-constants.ts";
59
66
 
60
67
  // L2 详情内容构建 + 滚动按键(纯函数)抽到 detail-content.ts(view 与其测试直接 import)。
61
68
  import { buildDetailContent, detailContentLength, type DetailScrollContext, processDetailKey } from "./detail-content.ts";
@@ -77,30 +84,10 @@ const TICK_MS = 200;
77
84
  /** 可打印 ASCII 字符下限(用于 save overlay 输入过滤)。 */
78
85
  const PRINTABLE_CHAR_MIN = 32;
79
86
 
80
- // ── 边框着色 helper(统一 borderMuted,避 ANSI 嵌套失色)──────────
81
- // 对齐 subagents list-component.ts 的 b/dash/dashes/titleBorder/plainBorder/walled。
82
- // 所有 ╭╮╰╯├┤┬┴─│ 统一走 borderMuted token,保证边框颜色一致。
83
-
84
- /** 着色单个框线字符(borderMuted)。 */
85
- function b(theme: ThemeLike, s: string): string {
86
- return theme.fg("borderMuted", s);
87
- }
88
- /** 着色单字符填充用的 ─(本文件 dashes() 满宽填充复用)。 */
89
- function dash(theme: ThemeLike): string {
90
- return theme.fg("borderMuted", "─");
91
- }
92
- /** 满宽 ─ 填充串(borderMuted)。n 次单字符着色,ANSI 自然延续。 */
93
- function dashes(theme: ThemeLike, n: number): string {
94
- return dash(theme).repeat(Math.max(0, n));
95
- }
96
- /** 纯线顶/底框(无标题):左角 + ─×W + 右角。 */
97
- function plainBorder(theme: ThemeLike, left: string, right: string, contentWidth: number): string {
98
- return b(theme, left) + dashes(theme, contentWidth) + b(theme, right);
99
- }
100
- /** 内容行墙:│ + 内容(pad 到 contentWidth) + │,墙字符 borderMuted。 */
101
- function walled(theme: ThemeLike, content: string, contentWidth: number): string {
102
- return `${b(theme, "│")}${padVisible(content, contentWidth)}${b(theme, "│")}`;
103
- }
87
+ // ── 边框着色 helper 家族(b/dash/dashes/titleBorder/plainBorder/walled)──
88
+ // 单定义 ../tui-kit.ts(post-convergence C4):本文件原私有自由函数副本与
89
+ // subagents list-component 的方法形态副本收敛为 kit 一份(本文件形态胜出),
90
+ // 统一 borderMuted token,所有 ╭╮╰╯├┤┬┴─│ 颜色一致。
104
91
 
105
92
  // ── Minimal TUI duck-types(避免直接 import TUI/KeybindingsManager 类型 ──
106
93
  // 共享类型 fallback 不导出 TUI 类,
@@ -142,17 +129,17 @@ export interface ViewActions {
142
129
  *
143
130
  * | 签名字段 | 消费点 |
144
131
  * |---|---|
145
- * | run.state.status | WorkflowsView.ts renderHeader :652 / detail-content.ts :75(statusDotStr+statusLabel)/ renderFooter :695-699 |
146
- * | 秒桶 Math.floor(now/1000) | renderHeader :651 formatElapsed(现算 elapsed)+ detail-content.ts :69-72(now 参与未完成节点 elapsed)|
147
- * | completed/total(节点 status 推导)| renderHeader :649-652 |
148
- * | budget 量化值(tokens round-k + cost toFixed(4)=BUDGET_COST_DECIMALS,与 :654 同精度——第 3-4 位小数变化是可见变化)| renderHeader :654 / saveTraceToFile :942 |
132
+ * | run.state.status | WorkflowsView.ts renderHeader :629 / detail-content.ts :75(statusDotStr+statusLabel)/ renderFooter :666-678 |
133
+ * | 秒桶 Math.floor(now/1000) | renderHeader :628 formatElapsed(现算 elapsed)+ detail-content.ts :69-72(now 参与未完成节点 elapsed)|
134
+ * | completed/total(节点 status 推导)| renderHeader :626-629 |
135
+ * | budget 量化值(tokens round-k + cost toFixed(4)=BUDGET_COST_DECIMALS,与 :631 同精度——第 3-4 位小数变化是可见变化)| renderHeader :631 / saveTraceToFile :980 |
149
136
  * | run.state.errorLogs 指纹 = length+末条 level:message(errorLogs 仅经 push + slice(-MAX_ERROR_LOGS) 变异——append-only + 前向淘汰、条目不可变;封顶后 length 不变内容移,单取 length 会漏失效,而任何可见变化必伴随 length 变或末条变,故 length+末条是完备且最小的指纹)| detail-content.ts :174-191(renderWorkerLogSection:total 标签 + 末 20 条)|
150
- * | 节点 stepIndex | nodeParts 首字段(trace 数组序参与拼接,节点重排→签名变)/ saveTraceToFile :947(trace 导出 `### [#stepIndex]` 标题)|
137
+ * | 节点 stepIndex | nodeParts 首字段(trace 数组序参与拼接,节点重排→签名变)/ saveTraceToFile :987(trace 导出 `### [#stepIndex]` 标题)|
151
138
  * | 节点 sessionFile | detail-content.ts :314-317(renderSessionSection)|
152
- * | 节点 status | 节点行 :832 statusDotStr / detail :75 |
153
- * | live.totalTokens | 节点行 :836 / detail :79 / :275 |
154
- * | 工具计数(签名路径用 countAllToolCalls 免克隆计数,与 getAllToolCalls(node.live).length 恒等——projectLiveProgress 投影无 toolCallCount 字段,不得引用)| 节点行 :837 / detail :80 / :223 |
155
- * | live.elapsedSeconds | 节点行 :838 / detail :81 / :276 |
139
+ * | 节点 status | 节点行 :844 statusDotStr / detail :75 |
140
+ * | live.totalTokens | 节点行 :819 / detail :79 / :275 |
141
+ * | 工具计数(签名路径用 countAllToolCalls 免克隆计数,与 getAllToolCalls(node.live).length 恒等——projectLiveProgress 投影无 toolCallCount 字段,不得引用)| 节点行 :820 / detail :80 / :223 |
142
+ * | live.elapsedSeconds | 节点行 :821 / detail :81 / :276 |
156
143
  * | live.turns | detail :224 / :276 |
157
144
  * | live.eventLog.length(append-only,长度变化即尾部窗口右移出新事件)| detail :222(filter turn_end)+ :231-235 |
158
145
  * | live.currentActivity(type+label 均入签名)| detail :227-228 |
@@ -291,14 +278,9 @@ export function createWorkflowsView(
291
278
  };
292
279
 
293
280
  // ── L2 详情滚动辅助 ──
294
- /** 安全读 terminal.rows(duck-type 失败兜底,对齐 subagents termRows)。 */
295
- function termRows(): number {
296
- const rows = tui.terminal?.rows;
297
- return typeof rows === "number" && rows > 0 ? rows : TERM_ROWS_FALLBACK;
298
- }
299
281
  /** L2 右侧 detail viewport 高度(与 renderLayout 的 viewH 同源)。 */
300
282
  function detailViewportHeight(): number {
301
- return Math.max(MIN_BODY_LINES, bodyHeight(termRows()));
283
+ return Math.max(MIN_BODY_LINES, bodyHeight(termRows(tui)));
302
284
  }
303
285
  /** 重置 detail 滚动到底部对齐(切 agent / 进 L2 时调用)。 */
304
286
  function resetDetailScroll(): void {
@@ -309,6 +291,8 @@ export function createWorkflowsView(
309
291
  // ── Key handling(SDK Component.handleInput 模式,对齐 main) ──
310
292
  // matchesKey(data, KeyId) 处理终端转义序列差异(xterm/iTerm/kitty),
311
293
  // 优于手写 \x1b[A 等原始序列。escape/ctrl+c 在 keybindings 里同映射到 exit。
294
+ // 阶段化编排(形态 1):主函数只留「saveMode 拦截 → 各键阶段短路链」,
295
+ // 各 handle*Key 返回是否消费该键;阶段顺序与原单函数 if 链逐一对齐。
312
296
  function handleInput(data: string): void {
313
297
  if (state.disposed) return;
314
298
 
@@ -318,108 +302,123 @@ export function createWorkflowsView(
318
302
  return;
319
303
  }
320
304
 
321
- // Escape / ctrl+c: level back or exit
322
- if (matchesKey(data, Key.escape)) {
323
- if (state.level === 0) {
324
- wrappedDone();
325
- return;
326
- }
327
- state.level = (state.level - 1) as NavLevel;
328
- state.promptExpanded = false;
329
- cache.key = undefined;
330
- requestRender();
331
- return;
305
+ if (handleEscapeKey(data)) return;
306
+ if (handleDetailScrollKey(data)) return;
307
+ if (handleNavUpKey(data)) return;
308
+ if (handleNavDownKey(data)) return;
309
+ if (handleEnterKey(data)) return;
310
+ handleShortcutKeys(data);
311
+ }
312
+
313
+ /** escape/ctrl+c:L0 关闭 overlay(wrappedDone),否则降一级并重绘。 */
314
+ function handleEscapeKey(data: string): boolean {
315
+ if (!matchesKey(data, Key.escape)) return false;
316
+ if (state.level === 0) {
317
+ wrappedDone();
318
+ return true;
332
319
  }
320
+ state.level = (state.level - 1) as NavLevel;
321
+ state.promptExpanded = false;
322
+ cache.key = undefined;
323
+ requestRender();
324
+ return true;
325
+ }
333
326
 
334
- // ── L2 详情滚动(PgUp/PgDn/Home/End,对齐 subagents processKey 阶段 2) ──
335
- // up/down 在 L2 用于切 agent,故滚动键独立于此;未命中则落到下面的 up/down/enter。
336
- if (state.level === NAV_LEVEL_DETAIL) {
337
- const node = currentPhaseAgents()[state.agentIdx];
338
- if (node) {
339
- const detailCtx: DetailScrollContext = {
340
- viewportHeight: detailViewportHeight(),
341
- contentLines: detailContentLength(node, state, run, theme),
342
- isRunning: node.status === "running",
343
- };
344
- const r = processDetailKey(
345
- data,
346
- { scrollOffset: state.detailScrollOffset, followTail: state.followTail },
347
- detailCtx,
348
- );
349
- if (r.handled) {
350
- state.detailScrollOffset = r.scrollOffset;
351
- state.followTail = r.followTail;
352
- cache.key = undefined;
353
- requestRender();
354
- return;
355
- }
356
- }
327
+ /** L2 详情滚动键(委托 processDetailKey)。非 L2 / 无节点 / 未命中 → 返回 false 落到导航键
328
+ * (与原嵌套 if 跳过语义一致:L2 无节点时 up/down 仍可切 agent)。 */
329
+ function handleDetailScrollKey(data: string): boolean {
330
+ if (state.level !== NAV_LEVEL_DETAIL) return false;
331
+ const node = currentPhaseAgents()[state.agentIdx];
332
+ if (!node) return false;
333
+ const detailCtx: DetailScrollContext = {
334
+ viewportHeight: detailViewportHeight(),
335
+ contentLines: detailContentLength(node, state, run, theme),
336
+ isRunning: node.status === "running",
337
+ };
338
+ const r = processDetailKey(
339
+ data,
340
+ { scrollOffset: state.detailScrollOffset, followTail: state.followTail },
341
+ detailCtx,
342
+ );
343
+ if (!r.handled) return false;
344
+ state.detailScrollOffset = r.scrollOffset;
345
+ state.followTail = r.followTail;
346
+ cache.key = undefined;
347
+ requestRender();
348
+ return true;
349
+ }
350
+
351
+ /** up:L0 上移 phase / L1、L2 上移 agent(L2 同时重置详情滚动)。命中 up 即消费(无条件重绘收尾)。 */
352
+ function handleNavUpKey(data: string): boolean {
353
+ if (!matchesKey(data, Key.up)) return false;
354
+ if (state.level === 0 && state.phaseIdx > 0) {
355
+ state.phaseIdx--;
356
+ state.agentIdx = 0;
357
+ state.phaseScrollOffset = 0; // 切 phase → 重置滚动
358
+ state.agentScrollOffset = 0; // 切 phase → 重置滚动
359
+ } else if (state.level === 1 && state.agentIdx > 0) {
360
+ state.agentIdx--;
361
+ // agentScrollOffset 由 renderLevel1 自动调整
362
+ } else if (state.level === NAV_LEVEL_DETAIL && state.agentIdx > 0) {
363
+ state.agentIdx--;
364
+ state.promptExpanded = false;
365
+ resetDetailScroll(); // 切 agent → 底部对齐(对齐 subagents 进详情即钉底)
357
366
  }
367
+ cache.key = undefined;
368
+ requestRender();
369
+ return true;
370
+ }
358
371
 
359
- // Navigation: up/down
360
- if (matchesKey(data, Key.up)) {
361
- if (state.level === 0 && state.phaseIdx > 0) {
362
- state.phaseIdx--;
372
+ /** down:L0/L1/L2 逐级下移(边界截停)。命中 down 即消费(无条件重绘收尾)。 */
373
+ function handleNavDownKey(data: string): boolean {
374
+ if (!matchesKey(data, Key.down)) return false;
375
+ if (state.level === 0) {
376
+ const live = buildPhaseGroups([...run.state.trace.toArray()]);
377
+ if (state.phaseIdx < live.length - 1) {
378
+ state.phaseIdx++;
363
379
  state.agentIdx = 0;
364
380
  state.phaseScrollOffset = 0; // 切 phase → 重置滚动
365
381
  state.agentScrollOffset = 0; // 切 phase → 重置滚动
366
- } else if (state.level === 1 && state.agentIdx > 0) {
367
- state.agentIdx--;
368
- // agentScrollOffset 由 renderLevel1 自动调整
369
- } else if (state.level === NAV_LEVEL_DETAIL && state.agentIdx > 0) {
370
- state.agentIdx--;
371
- state.promptExpanded = false;
372
- resetDetailScroll(); // 切 agent → 底部对齐(对齐 subagents 进详情即钉底)
373
382
  }
374
- cache.key = undefined;
375
- requestRender();
376
- return;
377
- }
378
- if (matchesKey(data, Key.down)) {
379
- if (state.level === 0) {
380
- const live = buildPhaseGroups([...run.state.trace.toArray()]);
381
- if (state.phaseIdx < live.length - 1) {
382
- state.phaseIdx++;
383
- state.agentIdx = 0;
384
- state.phaseScrollOffset = 0; // 切 phase → 重置滚动
385
- state.agentScrollOffset = 0; // 切 phase → 重置滚动
386
- }
387
- } else if (state.level === 1) {
388
- const agents = currentPhaseAgents();
389
- if (state.agentIdx < agents.length - 1) state.agentIdx++;
390
- // agentScrollOffset 由 renderLevel1 自动调整
391
- } else if (state.level === NAV_LEVEL_DETAIL) {
392
- const agents = currentPhaseAgents();
393
- if (state.agentIdx < agents.length - 1) {
394
- state.agentIdx++;
395
- state.promptExpanded = false;
396
- resetDetailScroll(); // 切 agent → 底部对齐
397
- }
383
+ } else if (state.level === 1) {
384
+ const agents = currentPhaseAgents();
385
+ if (state.agentIdx < agents.length - 1) state.agentIdx++;
386
+ // agentScrollOffset 由 renderLevel1 自动调整
387
+ } else if (state.level === NAV_LEVEL_DETAIL) {
388
+ const agents = currentPhaseAgents();
389
+ if (state.agentIdx < agents.length - 1) {
390
+ state.agentIdx++;
391
+ state.promptExpanded = false;
392
+ resetDetailScroll(); // 切 agent → 底部对齐
398
393
  }
399
- cache.key = undefined;
400
- requestRender();
401
- return;
402
394
  }
395
+ cache.key = undefined;
396
+ requestRender();
397
+ return true;
398
+ }
403
399
 
404
- // Enter: drill down (L0→L1→L2) or toggle prompt (L2)
405
- if (matchesKey(data, Key.enter)) {
406
- if (state.level === 0 && currentPhaseAgents().length > 0) {
407
- state.level = 1;
408
- state.agentIdx = 0;
409
- state.agentScrollOffset = 0; // 进 L1 → 重置滚动
410
- } else if (state.level === 1) {
411
- state.level = NAV_LEVEL_DETAIL;
412
- state.promptExpanded = false;
413
- resetDetailScroll(); // 进 L2 → 底部对齐
414
- } else if (state.level === NAV_LEVEL_DETAIL) {
415
- state.promptExpanded = !state.promptExpanded;
416
- // 展开/折叠改变内容长度,render 路径 clamp 收敛;保持当前锚点语义
417
- }
418
- cache.key = undefined;
419
- requestRender();
420
- return;
400
+ /** enter:L0→L1→L2 逐级下钻 / L2 切换 prompt 展开。命中 enter 即消费(无条件重绘收尾)。 */
401
+ function handleEnterKey(data: string): boolean {
402
+ if (!matchesKey(data, Key.enter)) return false;
403
+ if (state.level === 0 && currentPhaseAgents().length > 0) {
404
+ state.level = 1;
405
+ state.agentIdx = 0;
406
+ state.agentScrollOffset = 0; // 进 L1 → 重置滚动
407
+ } else if (state.level === 1) {
408
+ state.level = NAV_LEVEL_DETAIL;
409
+ state.promptExpanded = false;
410
+ resetDetailScroll(); // 进 L2 → 底部对齐
411
+ } else if (state.level === NAV_LEVEL_DETAIL) {
412
+ state.promptExpanded = !state.promptExpanded;
413
+ // 展开/折叠改变内容长度,render 路径 clamp 收敛;保持当前锚点语义
421
414
  }
415
+ cache.key = undefined;
416
+ requestRender();
417
+ return true;
418
+ }
422
419
 
420
+ /** lifecycle 快捷键(链尾,命中即消费且不再回退):a abort(仅 running)/ s 进 save mode / S 导出 trace。 */
421
+ function handleShortcutKeys(data: string): void {
423
422
  // ── Lifecycle shortcuts (no restart per D-9; no pause/resume — one-shot) ──
424
423
  if (data === "a") {
425
424
  if (run.state.status === "running") {
@@ -444,7 +443,6 @@ export function createWorkflowsView(
444
443
  // ── Trace export(对齐 main 的 S 键):导出完整 trace 到 Markdown 文件 ──
445
444
  if (data === "S") {
446
445
  saveTraceToFile(run, ctx);
447
- return;
448
446
  }
449
447
  }
450
448
 
@@ -641,12 +639,12 @@ function renderHeader(
641
639
  lines.push(walled(theme, nameLine, contentWidth));
642
640
 
643
641
  if (run.spec.description) {
644
- const maxDesc = contentWidth - visibleLen(rightPart) - 1;
642
+ const maxDesc = contentWidth - visibleWidth(rightPart) - 1;
645
643
  const descText = run.spec.description.length > maxDesc
646
644
  ? run.spec.description.slice(0, maxDesc - 1) + ELLIPSIS
647
645
  : run.spec.description;
648
646
  const descPart = theme.fg("dim", descText);
649
- const padLen = Math.max(0, contentWidth - visibleLen(descPart) - visibleLen(rightPart));
647
+ const padLen = Math.max(0, contentWidth - visibleWidth(descPart) - visibleWidth(rightPart));
650
648
  lines.push(`${b(theme, "│")}${descPart}${" ".repeat(padLen)}${rightPart}${b(theme, "│")}`);
651
649
  } else {
652
650
  lines.push(walled(theme, rightPart, contentWidth));
@@ -691,7 +689,7 @@ function mergeBody(
691
689
  ): void {
692
690
  const bodyHeightVal = Math.max(leftLines.length, rightLines.length);
693
691
  for (let i = 0; i < bodyHeightVal; i++) {
694
- const left = padVisible(leftLines[i] ?? "", SIDEBAR_WIDTH);
692
+ const left = padToVisible(leftLines[i] ?? "", SIDEBAR_WIDTH);
695
693
  lines.push(left + b(theme, "│") + (rightLines[i] ?? ""));
696
694
  }
697
695
  }
@@ -766,19 +764,16 @@ function renderLevel0(
766
764
 
767
765
  // ── Level 1: Agent selection ──────────────────────────────────
768
766
 
769
- function renderLevel1(
770
- lines: string[],
771
- _run: WorkflowRun,
767
+ /** L1 左列:sidebar title + phase list(固定高度 viewport + 滚动,只构建可见行)。
768
+ * 与 renderLevel0 左列同构——本 helper 只供 renderLevel1(renderLevel0 不在本单元目标内,不并轨,
769
+ * 避免扩大行为面;后续如消重可让 L0 复用)。 */
770
+ function buildLevelSidebar(
772
771
  phases: ReturnType<typeof buildPhaseGroups>,
773
772
  state: ViewState,
774
773
  theme: ThemeLike,
775
- mainWidth: number,
776
- now: number,
777
774
  bodyH: number,
778
- ): void {
775
+ ): string[] {
779
776
  const leftLines: string[] = [];
780
- const rightLines: string[] = [];
781
-
782
777
  // Left: sidebar title + phase list(固定高度 viewport + 滚动,只构建可见行)
783
778
  leftLines.push(theme.fg("muted", "Phases"));
784
779
  leftLines.push(dashes(theme, SIDEBAR_WIDTH));
@@ -789,10 +784,18 @@ function renderLevel1(
789
784
  state.phaseScrollOffset = phaseStart;
790
785
  while (leftLines.length < bodyH) leftLines.push("");
791
786
  leftLines.length = bodyH;
787
+ return leftLines;
788
+ }
792
789
 
793
- // Right: agent list(固定高度 viewport + 滚动)
790
+ /** L1 右列 header:phase 名 · agent 计数(无 phase 时 "(no phase)" 占位,两分支都补分隔线)。 */
791
+ function pushLevel1Header(
792
+ rightLines: string[],
793
+ phases: ReturnType<typeof buildPhaseGroups>,
794
+ state: ViewState,
795
+ theme: ThemeLike,
796
+ mainWidth: number,
797
+ ): void {
794
798
  const currentPhase = phases[state.phaseIdx];
795
- const agents = currentPhase?.nodes ?? [];
796
799
  if (currentPhase) {
797
800
  rightLines.push(theme.fg("muted", currentPhase.name
798
801
  ? `${currentPhase.name} · ${currentPhase.nodes.length} agents`
@@ -802,28 +805,67 @@ function renderLevel1(
802
805
  rightLines.push(theme.fg("muted", "(no phase)"));
803
806
  rightLines.push(dashes(theme, mainWidth));
804
807
  }
808
+ }
809
+
810
+ /** L1 agent 行统计三元组:live 路径优先(运行中从 node.live 读实时 token/tool + elapsed),
811
+ * 终态回退 result 统计。 */
812
+ function level1AgentCells(
813
+ node: ExecutionTraceNode,
814
+ now: number,
815
+ ): { tokStr: string; tcCount: number; elapsed: string } {
816
+ if (node.live) {
817
+ const live = projectLiveProgress(node.live);
818
+ return {
819
+ tokStr: live.totalTokens > 0 ? `${Math.round(live.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "",
820
+ tcCount: getAllToolCalls(node.live).length,
821
+ elapsed: formatElapsedSeconds(live.elapsedSeconds),
822
+ };
823
+ }
824
+ const elapsed = formatElapsed(
825
+ node.startedAt,
826
+ node.completedAt ? new Date(node.completedAt).getTime() : now,
827
+ );
828
+ const tok = node.result?.usage;
829
+ return {
830
+ tokStr: tok ? `${Math.round((tok.input + tok.output) / BUDGET_TOKENS_DIVISOR)}k tok` : "",
831
+ tcCount: node.result?.toolCalls?.length ?? 0,
832
+ elapsed,
833
+ };
834
+ }
835
+
836
+ /** L1 单个 agent 展示行:pointer + 状态点 + agent/model + tok/tools/elapsed(live 与终态同模板)。 */
837
+ function formatLevel1AgentLine(
838
+ node: ExecutionTraceNode,
839
+ theme: ThemeLike,
840
+ now: number,
841
+ selected: boolean,
842
+ ): string {
843
+ const pointer = selected ? "❯ " : " ";
844
+ const dot = statusDotStr(node.status, theme);
845
+ const { tokStr, tcCount, elapsed } = level1AgentCells(node, now);
846
+ return `${pointer}${dot} ${displayAgentName(node.agent)} ${node.model} ${tokStr} · ${tcCount} tools · ${elapsed}`;
847
+ }
848
+
849
+ function renderLevel1(
850
+ lines: string[],
851
+ _run: WorkflowRun,
852
+ phases: ReturnType<typeof buildPhaseGroups>,
853
+ state: ViewState,
854
+ theme: ThemeLike,
855
+ mainWidth: number,
856
+ now: number,
857
+ bodyH: number,
858
+ ): void {
859
+ const leftLines = buildLevelSidebar(phases, state, theme, bodyH);
860
+
861
+ // Right: agent list(固定高度 viewport + 滚动)
862
+ const rightLines: string[] = [];
863
+ pushLevel1Header(rightLines, phases, state, theme, mainWidth);
864
+ const currentPhase = phases[state.phaseIdx];
865
+ const agents = currentPhase?.nodes ?? [];
805
866
  const { startIdx: agentStart, viewportH: agentViewportH } = computeViewport(agents.length, state.agentIdx, bodyH);
806
867
  for (let i = agentStart; i < agentStart + agentViewportH && i < agents.length; i++) {
807
- const node = agents[i];
808
- const pointer = i === state.agentIdx ? "❯ " : " ";
809
- const dot = statusDotStr(node.status, theme);
810
- // Live 路径优先:运行中从 node.live 读实时 token/tool 计数 + elapsed。
811
- if (node.live) {
812
- const live = projectLiveProgress(node.live);
813
- const tokStr = live.totalTokens > 0 ? `${Math.round(live.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "";
814
- const tcCount = getAllToolCalls(node.live).length;
815
- const elapsed = formatElapsedSeconds(live.elapsedSeconds);
816
- rightLines.push(`${pointer}${dot} ${displayAgentName(node.agent)} ${node.model} ${tokStr} · ${tcCount} tools · ${elapsed}`);
817
- } else {
818
- const elapsed = formatElapsed(
819
- node.startedAt,
820
- node.completedAt ? new Date(node.completedAt).getTime() : now,
821
- );
822
- const tok = node.result?.usage;
823
- const tokStr = tok ? `${Math.round((tok.input + tok.output) / BUDGET_TOKENS_DIVISOR)}k tok` : "";
824
- const tcCount = node.result?.toolCalls?.length ?? 0;
825
- rightLines.push(`${pointer}${dot} ${displayAgentName(node.agent)} ${node.model} ${tokStr} · ${tcCount} tools · ${elapsed}`);
826
- }
868
+ rightLines.push(formatLevel1AgentLine(agents[i], theme, now, i === state.agentIdx));
827
869
  }
828
870
  state.agentScrollOffset = agentStart;
829
871
  while (rightLines.length < bodyH) rightLines.push("");
@@ -857,7 +899,7 @@ function renderLevel2(
857
899
  const pointer = i === state.agentIdx ? "❯ " : " ";
858
900
  const maxNameWidth = SIDEBAR_WIDTH - AGENT_NAME_BUDGET;
859
901
  const agentRef = displayAgentName(a.agent);
860
- const agentName = visibleLen(agentRef) > maxNameWidth
902
+ const agentName = visibleWidth(agentRef) > maxNameWidth
861
903
  ? agentRef.slice(0, maxNameWidth - 1) + ELLIPSIS
862
904
  : agentRef;
863
905
  leftLines.push(`${pointer}${agentName}`);
@@ -909,34 +951,18 @@ const TRACE_ACTIVITY_WIDTH = 80;
909
951
  * 导出完整 workflow trace 到 Markdown 文件。
910
952
  * 路径:<agentDir>/workflow-traces/{runId}.md(agentDir = getAgentDir(),实例隔离)
911
953
  * 对齐 main 的 saveTraceToFile(WorkflowsView.ts:365-396)。
954
+ * 阶段化编排:header 行 + 逐 phase 逐 node 全段同步拼完后,才走 mkdir→write→notify 异步链
955
+ * (与原单函数时序一致:文件内容构建全部先于首次 fs 调用)。
912
956
  */
913
957
  function saveTraceToFile(run: WorkflowRun, ctx: ExtensionContext): void {
914
958
  const dir = pathJoin(getAgentDir(), "workflow-traces");
915
959
  const filePath = pathJoin(dir, `${run.runId}.md`);
916
- const lines: string[] = [];
917
- lines.push(`# Workflow Trace: ${run.spec.scriptName} (${run.runId})`, "");
918
- lines.push(`Status: ${run.state.status} | Started: ${run.meta.startedAt ?? "-"} | Duration: ${formatElapsed(run.meta.startedAt)}`);
919
- const budget = run.state.budget;
920
- lines.push(`Budget: ${budget.usedTokens}/${budget.maxTokens ?? "unlimited"} tokens, $${budget.usedCost.toFixed(BUDGET_COST_DECIMALS)}`, "");
960
+ const lines = traceHeaderLines(run);
921
961
  const phases = buildPhaseGroups([...run.state.trace.toArray()]);
922
962
  for (const pg of phases) {
923
963
  lines.push(`## Phase: ${pg.name || "(unnamed)"}`, "");
924
964
  for (const node of pg.nodes) {
925
- lines.push(`### [#${node.stepIndex}] ${node.agent} — ${node.status}`);
926
- lines.push(`- Model: ${node.model}`);
927
- lines.push(`- Duration: ${formatElapsed(node.startedAt, node.completedAt ? new Date(node.completedAt).getTime() : Date.now())}`, "");
928
- lines.push("**Prompt:**", node.task, "");
929
- const toolCalls = node.result?.toolCalls ?? [];
930
- if (toolCalls.length > 0) {
931
- lines.push("**Activity:**");
932
- for (const tc of toolCalls) lines.push(`- ${formatActivityLine(tc, TRACE_ACTIVITY_WIDTH)}`);
933
- lines.push("");
934
- }
935
- lines.push("**Outcome:**");
936
- if (node.status === "running") lines.push("Still running...");
937
- else if (node.result?.error) lines.push(node.result.error);
938
- else if (node.result?.content) lines.push(node.result.content.slice(0, TRACE_OUTCOME_SLICE));
939
- lines.push("");
965
+ lines.push(...traceNodeLines(node));
940
966
  }
941
967
  }
942
968
  fsPromises.mkdir(dir, { recursive: true })
@@ -945,6 +971,51 @@ function saveTraceToFile(run: WorkflowRun, ctx: ExtensionContext): void {
945
971
  .catch((err: Error) => ctx.ui.notify(`Save failed: ${err.message}`, "error"));
946
972
  }
947
973
 
974
+ /** trace 导出头部三行:标题 + 空行、Status/Started/Duration、Budget + 空行。 */
975
+ function traceHeaderLines(run: WorkflowRun): string[] {
976
+ const lines: string[] = [];
977
+ lines.push(`# Workflow Trace: ${run.spec.scriptName} (${run.runId})`, "");
978
+ lines.push(`Status: ${run.state.status} | Started: ${run.meta.startedAt ?? "-"} | Duration: ${formatElapsed(run.meta.startedAt)}`);
979
+ const budget = run.state.budget;
980
+ lines.push(`Budget: ${budget.usedTokens}/${budget.maxTokens ?? "unlimited"} tokens, $${budget.usedCost.toFixed(BUDGET_COST_DECIMALS)}`, "");
981
+ return lines;
982
+ }
983
+
984
+ /** 单节点全段:### 标题 + Model/Duration + Prompt + Activity + Outcome(行序同原单函数版)。 */
985
+ function traceNodeLines(node: ExecutionTraceNode): string[] {
986
+ const lines: string[] = [];
987
+ lines.push(`### [#${node.stepIndex}] ${node.agent} — ${node.status}`);
988
+ lines.push(`- Model: ${node.model}`);
989
+ lines.push(`- Duration: ${formatElapsed(node.startedAt, node.completedAt ? new Date(node.completedAt).getTime() : Date.now())}`, "");
990
+ lines.push("**Prompt:**", node.task, "");
991
+ lines.push(...traceActivityLines(node));
992
+ lines.push(...traceOutcomeLines(node));
993
+ return lines;
994
+ }
995
+
996
+ /** 单节点 Activity 段:**Activity:** + 每条 toolCall 一行 + 空行;无 toolCall 时整段省略。 */
997
+ function traceActivityLines(node: ExecutionTraceNode): string[] {
998
+ const lines: string[] = [];
999
+ const toolCalls = node.result?.toolCalls ?? [];
1000
+ if (toolCalls.length > 0) {
1001
+ lines.push("**Activity:**");
1002
+ for (const tc of toolCalls) lines.push(`- ${formatActivityLine(tc, TRACE_ACTIVITY_WIDTH)}`);
1003
+ lines.push("");
1004
+ }
1005
+ return lines;
1006
+ }
1007
+
1008
+ /** 单节点 Outcome 段:**Outcome:** + running/error/content(截断)三态互斥 + 空行。 */
1009
+ function traceOutcomeLines(node: ExecutionTraceNode): string[] {
1010
+ const lines: string[] = [];
1011
+ lines.push("**Outcome:**");
1012
+ if (node.status === "running") lines.push("Still running...");
1013
+ else if (node.result?.error) lines.push(node.result.error);
1014
+ else if (node.result?.content) lines.push(node.result.content.slice(0, TRACE_OUTCOME_SLICE));
1015
+ lines.push("");
1016
+ return lines;
1017
+ }
1018
+
948
1019
  // ── Save overlay(缺陷 #3 恢复,从 main 移植简化版)────────────
949
1020
 
950
1021
  /**
@@ -13,9 +13,9 @@
13
13
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
14
14
 
15
15
  import { computeRenderSignature } from "../WorkflowsView.ts";
16
- import type { ExecutionRecord } from "@zhushanwen/subagent-core/execution/types.ts";
17
- import type { ExecutionTraceNode, WorkerLogEntry } from "@zhushanwen/subagent-core/orchestration/models/types.ts";
18
- import type { WorkflowRun } from "@zhushanwen/subagent-core/orchestration/models/workflow-run.ts";
16
+ import type { ExecutionRecord } from "@zhushanwen/subagent-core";
17
+ import type { ExecutionTraceNode, WorkerLogEntry } from "@zhushanwen/subagent-core";
18
+ import type { WorkflowRun } from "@zhushanwen/subagent-core";
19
19
 
20
20
  // ── Fixtures(duck typing,对齐 detail-content-session-file.test.ts 先例)──
21
21
 
@@ -8,14 +8,17 @@
8
8
  import { describe, expect, it } from "vitest";
9
9
 
10
10
  import { buildDetailContent } from "../detail-content.ts";
11
- import type { ThemeLike } from "../format.ts";
12
- import type { ExecutionTraceNode } from "@zhushanwen/subagent-core/orchestration/models/types.ts";
13
- import type { WorkflowRun } from "@zhushanwen/subagent-core/orchestration/models/workflow-run.ts";
11
+ import type { ThemeLike } from "../../format.ts";
12
+ import type { ExecutionTraceNode } from "@zhushanwen/subagent-core";
13
+ import type { WorkflowRun } from "@zhushanwen/subagent-core";
14
14
 
15
- /** plain theme: fg/bold 直接返回原文,无 ANSI 色码——测试断言纯文本。 */
15
+ /** plain theme: 着色方法直接返回原文,无 ANSI 色码——测试断言纯文本。
16
+ * bg/underline 为宽 ThemeLike 接口的 no-op 实现(workflow 渲染面只用 fg/bold)。 */
16
17
  const plainTheme: ThemeLike = {
18
+ bg: (_color: string, text: string) => text,
17
19
  fg: (_tag: string, text: string) => text,
18
20
  bold: (text: string) => text,
21
+ underline: (text: string) => text,
19
22
  };
20
23
 
21
24
  function makeNode(overrides: Partial<ExecutionTraceNode> = {}): ExecutionTraceNode {