@zhushanwen/pi-subagent-workflow 0.1.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 (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * L2 详情内容构建 + 滚动按键处理(纯函数)。
3
+ *
4
+ * 从 WorkflowsView.ts 抽出,目的:
5
+ * 1. 把 WorkflowsView.ts 控制在 1000 行以内(行数检查 hook)
6
+ * 2. buildDetailContent / detailContentLength / processDetailKey 为导出纯函数,
7
+ * 无 Pi runtime 依赖,可直接单测(对齐 subagents list-view 的 processKey 模式)
8
+ *
9
+ * 单一数据源:renderLevel2 渲染与 detailContentLength 算行数都走 buildDetailContent,
10
+ * 避免两者发散(对齐 subagents buildDetailContent / detailContentLength)。
11
+ */
12
+
13
+ import { Key, matchesKey } from "@mariozechner/pi-tui";
14
+
15
+ import { getAllToolCalls, projectLiveProgress } from "../../execution/execution-record.ts";
16
+ import type { AgentEventLogEntry } from "../../execution/types.ts";
17
+ import type { ExecutionTraceNode } from "../../orchestration/models/types.ts";
18
+ import type { WorkflowRun } from "../../orchestration/models/workflow-run.ts";
19
+ import {
20
+ BOX_BORDER_CHARS,
21
+ BUDGET_TOKENS_DIVISOR,
22
+ ELLIPSIS,
23
+ formatActivityLine,
24
+ formatElapsed,
25
+ formatElapsedSeconds,
26
+ formatEventLine,
27
+ formatTokenStat,
28
+ MAX_TOOL_CALLS_DISPLAY,
29
+ OUTPUT_TRUNCATE_BYTES,
30
+ PAGE_SCROLL_DEFAULT,
31
+ PROMPT_FOLD_LINES,
32
+ statusDotStr,
33
+ type ThemeLike,
34
+ } from "./format.ts";
35
+
36
+ // ── 共享常量(BOX_BORDER_CHARS / BUDGET_TOKENS_DIVISOR / MAX_TOOL_CALLS_DISPLAY
37
+ // 已上移到 format.ts,供 WorkflowsView + detail-content 共用,避免重复定义)──
38
+
39
+ /** 探测宽度:足够大避免截断折行影响行数统计(对齐 subagents DETAIL_LEN_PROBE_WIDTH)。 */
40
+ const DETAIL_LEN_PROBE_WIDTH = 9999;
41
+
42
+ /** status → 语义色标签(L2 detail 头用)。 */
43
+ export function statusLabel(status: string, theme: ThemeLike): string {
44
+ switch (status) {
45
+ case "completed": return theme.fg("success", status);
46
+ case "running": return theme.fg("warning", status);
47
+ case "failed": return theme.fg("error", status);
48
+ default: return theme.fg("muted", status);
49
+ }
50
+ }
51
+
52
+ // ── L2 详情内容构建(单一数据源)──────────────────────────────────
53
+
54
+ /**
55
+ * 构建 L2 右侧详情的完整内容行。
56
+ *
57
+ * 纯函数:无 Pi runtime、无副作用,可单测。入参 promptExpanded 用结构化类型,
58
+ * 不依赖完整 ViewState(便于测试构造)。
59
+ */
60
+ export function buildDetailContent(
61
+ node: ExecutionTraceNode,
62
+ state: { promptExpanded: boolean },
63
+ run: WorkflowRun,
64
+ theme: ThemeLike,
65
+ mainWidth: number,
66
+ now: number,
67
+ ): string[] {
68
+ const rightLines: string[] = [];
69
+ const elapsed = formatElapsed(
70
+ node.startedAt,
71
+ node.completedAt ? new Date(node.completedAt).getTime() : now,
72
+ );
73
+ rightLines.push(theme.fg("muted", "Detail"));
74
+ rightLines.push("─".repeat(mainWidth));
75
+ rightLines.push(`${statusDotStr(node.status, theme)} ${statusLabel(node.status, theme)} · ${node.model}`);
76
+ // Live 路径优先:运行中用 node.live 的实时 usage/toolCalls/elapsed;否则用终态 result。
77
+ if (node.live) {
78
+ const live = projectLiveProgress(node.live);
79
+ const tokK = live.totalTokens > 0 ? `${Math.round(live.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "0 tok";
80
+ const tcCount = getAllToolCalls(node.live).length;
81
+ rightLines.push(theme.fg("dim", `${tokK} · ${tcCount} tool calls · ${formatElapsedSeconds(live.elapsedSeconds)}`));
82
+ } else {
83
+ rightLines.push(theme.fg("dim", formatTokenStat(node.result?.usage, node.result?.toolCalls, elapsed)));
84
+ }
85
+ rightLines.push("");
86
+ renderWorkerLogSection(rightLines, run, mainWidth, theme);
87
+ renderPromptSection(rightLines, node, state, theme);
88
+ renderActivitySection(rightLines, node, mainWidth, theme);
89
+ renderOutcomeSection(rightLines, node, mainWidth, theme);
90
+ return rightLines;
91
+ }
92
+
93
+ /** L2 详情内容总行数(供 processDetailKey 算 max,不重复生成内容)。 */
94
+ export function detailContentLength(
95
+ node: ExecutionTraceNode,
96
+ state: { promptExpanded: boolean },
97
+ run: WorkflowRun,
98
+ theme: ThemeLike,
99
+ ): number {
100
+ return buildDetailContent(node, state, run, theme, DETAIL_LEN_PROBE_WIDTH, Date.now()).length;
101
+ }
102
+
103
+ // ── L2 详情滚动按键(纯函数,对齐 subagents processKey)─────────
104
+
105
+ /** 详情翻屏上下文:视口高 + 内容总行数 + 是否 running(驱动 followTail)。 */
106
+ export interface DetailScrollContext {
107
+ /** 右侧 detail 可见行数(render 的 viewH,单一数据源)。 */
108
+ viewportHeight: number;
109
+ /** buildDetailContent 总行数。 */
110
+ contentLines: number;
111
+ /** node.status === "running"(决定 followTail 语义)。 */
112
+ isRunning: boolean;
113
+ }
114
+
115
+ /** 滚动按键处理结果(纯数据,调用方回写 state)。 */
116
+ export interface DetailKeyResult {
117
+ /** 新的滚动 offset(已 clamp 到 [0, max])。 */
118
+ scrollOffset: number;
119
+ /** 是否继续"钉底部"(running 态自动跟随最新输出)。 */
120
+ followTail: boolean;
121
+ /** 是否命中滚动键(false → 调用方回退到 up/down/enter 等现有逻辑)。 */
122
+ handled: boolean;
123
+ }
124
+
125
+ /**
126
+ * 处理 L2 详情滚动按键(PgUp/PgDn/Home/End)。
127
+ *
128
+ * - PgUp → offset -= viewportHeight,followTail=false(用户主动上滚,停止跟随)
129
+ * - PgDn → offset += viewportHeight;到底则 followTail=true
130
+ * - Home → offset=0,followTail=false
131
+ * - End → offset=max,followTail=true
132
+ * - 其他 → handled=false(交回 handleInput 现有逻辑)
133
+ *
134
+ * max = max(0, contentLines - viewportHeight)。纯函数:入参 data + state + ctx,
135
+ * 无副作用、无 Pi 依赖,可直接单测(对齐 subagents processKey)。
136
+ */
137
+ export function processDetailKey(
138
+ data: string,
139
+ state: { scrollOffset: number; followTail: boolean },
140
+ ctx: DetailScrollContext,
141
+ ): DetailKeyResult {
142
+ const viewH = Math.max(1, ctx.viewportHeight);
143
+ const max = Math.max(0, ctx.contentLines - viewH);
144
+ const off = state.scrollOffset;
145
+
146
+ if (matchesKey(data, Key.pageUp)) {
147
+ const step = ctx.viewportHeight > 0 ? ctx.viewportHeight : PAGE_SCROLL_DEFAULT;
148
+ return { scrollOffset: Math.max(0, off - step), followTail: false, handled: true };
149
+ }
150
+ if (matchesKey(data, Key.pageDown)) {
151
+ const step = ctx.viewportHeight > 0 ? ctx.viewportHeight : PAGE_SCROLL_DEFAULT;
152
+ const next = Math.min(max, off + step);
153
+ // 到底恢复跟随(用户 PgDn 翻到底 = 想看最新)
154
+ return { scrollOffset: next, followTail: next >= max, handled: true };
155
+ }
156
+ if (matchesKey(data, Key.home)) {
157
+ return { scrollOffset: 0, followTail: false, handled: true };
158
+ }
159
+ if (matchesKey(data, Key.end)) {
160
+ return { scrollOffset: max, followTail: true, handled: true };
161
+ }
162
+ return { scrollOffset: off, followTail: state.followTail, handled: false };
163
+ }
164
+
165
+ // ── L2 详情区段渲染(buildDetailContent 调用)─────────────────────
166
+
167
+ function renderWorkerLogSection(
168
+ rightLines: string[],
169
+ run: WorkflowRun,
170
+ mainWidth: number,
171
+ theme: ThemeLike,
172
+ ): void {
173
+ const logs = run.state.errorLogs;
174
+ if (!logs || logs.length === 0) return;
175
+ const total = logs.length;
176
+ const WORKER_LOG_SHOW = 20;
177
+ const showCount = Math.min(total, WORKER_LOG_SHOW);
178
+ const label = total > showCount
179
+ ? `Worker diagnostics · last ${showCount} of ${total}`
180
+ : `Worker diagnostics · ${total} entr${total !== 1 ? "ies" : "y"}`;
181
+ rightLines.push(theme.fg("warning", label));
182
+ const start = total - showCount;
183
+ for (let i = start; i < total; i++) {
184
+ const entry = logs[i];
185
+ const levelToken = entry.level === "error" ? "error" : entry.level === "warn" ? "warning" : "muted";
186
+ const prefix = `[${entry.level}]`;
187
+ const line = ` ${prefix} ${entry.message}`.slice(0, mainWidth - BOX_BORDER_CHARS);
188
+ rightLines.push(theme.fg(levelToken, line));
189
+ }
190
+ rightLines.push("");
191
+ }
192
+
193
+ function renderPromptSection(
194
+ rightLines: string[],
195
+ node: ExecutionTraceNode,
196
+ state: { promptExpanded: boolean },
197
+ theme: ThemeLike,
198
+ ): void {
199
+ const taskLines = node.task.split("\n");
200
+ const lineCount = taskLines.length;
201
+ rightLines.push(theme.fg("muted", `Prompt · ${lineCount} lines · ⏎ ${state.promptExpanded ? "collapse" : "expand"}`));
202
+ if (state.promptExpanded || lineCount <= PROMPT_FOLD_LINES) {
203
+ rightLines.push(...taskLines.map((l) => ` ${l}`));
204
+ } else {
205
+ rightLines.push(...taskLines.slice(0, PROMPT_FOLD_LINES).map((l) => ` ${l}`));
206
+ rightLines.push(theme.fg("dim", ` ${ELLIPSIS} ${lineCount - PROMPT_FOLD_LINES} more lines`));
207
+ }
208
+ rightLines.push("");
209
+ }
210
+
211
+ function renderActivitySection(
212
+ rightLines: string[],
213
+ node: ExecutionTraceNode,
214
+ mainWidth: number,
215
+ theme: ThemeLike,
216
+ ): void {
217
+ // Live 路径:agent 运行中,从 node.live 派生实时 eventLog + currentActivity。
218
+ // 与 subagents TUI 一致:当前活动行 + 最近 N 条离散事件(tool/turn_end/error)。
219
+ if (node.live) {
220
+ const live = projectLiveProgress(node.live);
221
+ const eventLog = live.eventLog.filter((e) => e.type !== "turn_end");
222
+ const totalCount = getAllToolCalls(node.live).length;
223
+ const label = `Activity · ${totalCount} tool call${totalCount !== 1 ? "s" : ""} · ${live.turns} turn${live.turns !== 1 ? "s" : ""}`;
224
+ rightLines.push(theme.fg("muted", label));
225
+ // 当前活动行(running tool / thinking / text)
226
+ if (live.currentActivity) {
227
+ rightLines.push(theme.fg("accent", ` ⎿ ${live.currentActivity.type}: ${live.currentActivity.label}`.slice(0, mainWidth - BOX_BORDER_CHARS)));
228
+ }
229
+ // 最近 N 条事件
230
+ const showCount = Math.min(MAX_TOOL_CALLS_DISPLAY, eventLog.length);
231
+ const start = eventLog.length - showCount;
232
+ for (let i = start; i < eventLog.length; i++) {
233
+ const entry = eventLog[i] as AgentEventLogEntry;
234
+ rightLines.push(theme.fg("dim", ` ${formatEventLine(entry, theme)}`.slice(0, mainWidth - BOX_BORDER_CHARS)));
235
+ }
236
+ if (totalCount === 0 && !live.currentActivity) {
237
+ rightLines.push(theme.fg("dim", " (starting...)"));
238
+ }
239
+ rightLines.push("");
240
+ return;
241
+ }
242
+
243
+ // 终态路径:从 node.result.toolCalls 读(原有逻辑)
244
+ const toolCalls = node.result?.toolCalls ?? [];
245
+ const totalCount = toolCalls.length;
246
+ if (totalCount > 0) {
247
+ const showCount = Math.min(MAX_TOOL_CALLS_DISPLAY, totalCount);
248
+ const isTruncated = totalCount > MAX_TOOL_CALLS_DISPLAY;
249
+ const label = isTruncated
250
+ ? `Activity · last ${showCount} of ${totalCount} tool calls`
251
+ : `Activity · ${totalCount} tool call${totalCount !== 1 ? "s" : ""}`;
252
+ rightLines.push(theme.fg("muted", label));
253
+ const start = totalCount - showCount;
254
+ for (let i = start; i < totalCount; i++) {
255
+ rightLines.push(` ${formatActivityLine(toolCalls[i], mainWidth - BOX_BORDER_CHARS)}`);
256
+ }
257
+ } else {
258
+ rightLines.push(theme.fg("muted", "Activity"));
259
+ rightLines.push(theme.fg("dim", ` ${node.status === "running" ? "(no tool calls yet)" : "(no activity recorded)"}`));
260
+ }
261
+ rightLines.push("");
262
+ }
263
+
264
+ function renderOutcomeSection(
265
+ rightLines: string[],
266
+ node: ExecutionTraceNode,
267
+ mainWidth: number,
268
+ theme: ThemeLike,
269
+ ): void {
270
+ rightLines.push(theme.fg("muted", "Outcome"));
271
+ if (node.status === "running" && node.live) {
272
+ // 运行中:显示实时指标(elapsed/tokens/turns)替代空荡的 "Still running..."
273
+ const live = projectLiveProgress(node.live);
274
+ const tokK = live.totalTokens > 0 ? `${Math.round(live.totalTokens / 1000)}k tok` : "0 tok";
275
+ rightLines.push(theme.fg("dim", ` Running · ${formatElapsedSeconds(live.elapsedSeconds)} · ${tokK} · ${live.turns} turn${live.turns !== 1 ? "s" : ""}`));
276
+ if (live.lastError) {
277
+ rightLines.push(theme.fg("warning", ` ⚠ ${live.lastError.slice(0, mainWidth - BOX_BORDER_CHARS)}`));
278
+ }
279
+ } else if (node.status === "running") {
280
+ rightLines.push(theme.fg("dim", " Still running..."));
281
+ } else if (node.result?.error) {
282
+ rightLines.push(theme.fg("error", ` ${node.result.error.slice(0, mainWidth - BOX_BORDER_CHARS)}`));
283
+ } else if (node.result?.content) {
284
+ const raw = node.result.content;
285
+ const OUTCOME_TAIL_LINES = 5;
286
+ if (Buffer.byteLength(raw, "utf8") > OUTPUT_TRUNCATE_BYTES) {
287
+ const truncated = Buffer.from(raw, "utf8").slice(0, OUTPUT_TRUNCATE_BYTES).toString("utf8");
288
+ const allLines = truncated.split("\n");
289
+ const tail = allLines.slice(-OUTCOME_TAIL_LINES);
290
+ rightLines.push(...tail.map((l) => ` ${l.slice(0, mainWidth - BOX_BORDER_CHARS)}`));
291
+ rightLines.push(theme.fg("dim", " (truncated)"));
292
+ } else {
293
+ const allLines = raw.split("\n");
294
+ const tail = allLines.slice(-OUTCOME_TAIL_LINES);
295
+ rightLines.push(...tail.map((l) => ` ${l.slice(0, mainWidth - BOX_BORDER_CHARS)}`));
296
+ }
297
+ }
298
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Workflow View — Pure formatting functions (FR-4)
3
+ *
4
+ * Stateless functions extracted from WorkflowsView for testability.
5
+ * All functions are pure: no Pi runtime, no side effects.
6
+ */
7
+
8
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
9
+
10
+ import type { AgentEventLogEntry } from "../../execution/types.ts";
11
+ import type { ExecutionTraceNode, ToolCallEntry } from "../../orchestration/models/types.ts";
12
+ import type { DoneReason, RunStatus } from "../../orchestration/models/types.ts";
13
+
14
+ // ── Constants ─────────────────────────────────────────────────
15
+
16
+ export const SIDEBAR_WIDTH = 24;
17
+ export const PROMPT_FOLD_LINES = 3;
18
+ export const OUTPUT_TRUNCATE_BYTES = 100_000;
19
+ export const ELLIPSIS = "\u2026"; // U+2026
20
+
21
+ // L2 详情滚动常量(对齐 subagents list-view.ts)。
22
+ /** terminal.rows 读不到时的翻页兜底步长(防 NaN)。 */
23
+ export const PAGE_SCROLL_DEFAULT = 10;
24
+ /** tui.terminal.rows 兜底行数(duck-type 失败时,对齐 subagents TERM_ROWS_FALLBACK)。 */
25
+ export const TERM_ROWS_FALLBACK = 24;
26
+
27
+ // 跨 view 共享的布局常量(WorkflowsView + detail-content 都用)。
28
+ /** box 左右边框字符宽度(│ x 2),用于内容行截断预算。 */
29
+ export const BOX_BORDER_CHARS = 2;
30
+ /** token 数 → k 单位的除数。 */
31
+ export const BUDGET_TOKENS_DIVISOR = 1000;
32
+ /** Activity 区最多显示的 tool call 条数。 */
33
+ export const MAX_TOOL_CALLS_DISPLAY = 3;
34
+
35
+ // 时间换算(模块私有常量)。
36
+ const MS_PER_SEC = 1000;
37
+ const SECS_PER_MIN = 60;
38
+
39
+ /**
40
+ * 可显示的状态文本集合。
41
+ *
42
+ * 包含 RunStatus("running"|"paused"|"done" 不直接显示,转 reason)+ DoneReason
43
+ * (completed/failed/aborted/budget_limited/time_limited)+ ExecutionTraceNode.status
44
+ * (含 "pending"——trace 节点的初始态)。
45
+ *
46
+ * 收窄自 string → 显式联合,编译器会在新增 status 时强制 switch 补齐分支。
47
+ */
48
+ type StatusText =
49
+ | RunStatus
50
+ | DoneReason
51
+ | "pending";
52
+
53
+ // ── Theme interface (avoids importing Pi runtime) ─────────────
54
+
55
+ export interface ThemeLike {
56
+ fg(token: string, text: string): string;
57
+ bold(text: string): string;
58
+ }
59
+
60
+ // ── Status helpers ────────────────────────────────────────────
61
+
62
+ /** status → 语义颜色 token(用于给任意文本染色,不含符号)。 */
63
+ function statusColorToken(
64
+ status: StatusText,
65
+ ): "success" | "warning" | "error" | "muted" {
66
+ switch (status) {
67
+ case "completed": return "success";
68
+ case "running": return "warning";
69
+ case "failed": case "aborted": return "error";
70
+ default: return "muted";
71
+ }
72
+ }
73
+
74
+ export function statusDotStr(
75
+ status: StatusText,
76
+ theme: ThemeLike,
77
+ ): string {
78
+ return theme.fg(statusColorToken(status), "●");
79
+ }
80
+
81
+ /** Format a status badge with color for the header area. */
82
+ export function formatStatusBadge(
83
+ status: StatusText,
84
+ theme: ThemeLike,
85
+ ): string {
86
+ switch (status) {
87
+ case "running": return theme.fg("warning", "\u25CF running");
88
+ case "paused": return theme.fg("warning", "\u23F8 PAUSED");
89
+ case "completed": return theme.fg("success", "\u2713 completed");
90
+ case "failed": return theme.fg("error", "\u2717 failed");
91
+ case "aborted": return theme.fg("error", "\u2717 aborted");
92
+ case "budget_limited": return theme.fg("error", "\u26A0 budget");
93
+ case "time_limited": return theme.fg("error", "\u26A0 timeout");
94
+ default: return theme.fg("muted", status);
95
+ }
96
+ }
97
+
98
+ // ── Pure formatting functions ─────────────────────────────────
99
+
100
+ /** Group trace nodes by phase. Nodes without phase go to "(no phase)". */
101
+ function groupByPhase(nodes: ExecutionTraceNode[]): Map<string, ExecutionTraceNode[]> {
102
+ const map = new Map<string, ExecutionTraceNode[]>();
103
+ for (const node of nodes) {
104
+ const phase = node.phase || "(default)";
105
+ let arr = map.get(phase);
106
+ if (!arr) {
107
+ arr = [];
108
+ map.set(phase, arr);
109
+ }
110
+ arr.push(node);
111
+ }
112
+ // Sort within each phase by stepIndex ascending (FR-3.2)
113
+ for (const arr of map.values()) {
114
+ arr.sort((a, b) => a.stepIndex - b.stepIndex);
115
+ }
116
+ return map;
117
+ }
118
+
119
+ /** Format elapsed time string from startedAt. */
120
+ export function formatElapsed(startedAt?: string, now: number = Date.now()): string {
121
+ if (!startedAt) return "-";
122
+ const ms = now - new Date(startedAt).getTime();
123
+ if (ms < MS_PER_SEC) return "0s";
124
+ const secs = Math.floor(ms / MS_PER_SEC);
125
+ if (secs < SECS_PER_MIN) return `${secs}s`;
126
+ const mins = Math.floor(secs / SECS_PER_MIN);
127
+ const remSecs = secs % SECS_PER_MIN;
128
+ return `${mins}m${remSecs}s`;
129
+ }
130
+
131
+ /**
132
+ * Format elapsed time from integer seconds(live 路径用)。
133
+ * 与 formatElapsed 输出格式一致,但输入是 computeElapsedSeconds 的秒数(非时间戳)。
134
+ */
135
+ export function formatElapsedSeconds(seconds: number): string {
136
+ if (seconds < 1) return "0s";
137
+ if (seconds < SECS_PER_MIN) return `${seconds}s`;
138
+ const mins = Math.floor(seconds / SECS_PER_MIN);
139
+ const remSecs = seconds % SECS_PER_MIN;
140
+ return `${mins}m${remSecs}s`;
141
+ }
142
+
143
+ /**
144
+ * Format a live eventLog entry(live 路径 Activity 区用)。
145
+ *
146
+ * tool_start → "→ {label}"
147
+ * tool_end → "← {label}"(done)/ "✗ {label}"(failed)
148
+ * turn_end → "∘ {label}"(turn 摘要)
149
+ * error → "✗ {label}"
150
+ *
151
+ * 对齐 subagents formatEventLine 的视觉风格,但用 workflow 的 ThemeLike(无 spinner)。
152
+ */
153
+ export function formatEventLine(entry: AgentEventLogEntry, theme: ThemeLike): string {
154
+ switch (entry.type) {
155
+ case "tool_start":
156
+ return `→ ${entry.label}`;
157
+ case "tool_end":
158
+ return entry.status === "failed"
159
+ ? theme.fg("error", `✗ ${entry.label}`)
160
+ : `✓ ${entry.label}`;
161
+ case "turn_end":
162
+ return theme.fg("dim", `∘ ${entry.label}`);
163
+ case "error":
164
+ return theme.fg("error", `✗ ${entry.label}`);
165
+ default:
166
+ return entry.label;
167
+ }
168
+ }
169
+
170
+ /** Format token + tool call statistics. */
171
+ export function formatTokenStat(
172
+ usage?: { input: number; output: number },
173
+ toolCalls?: ToolCallEntry[],
174
+ elapsed?: string,
175
+ ): string {
176
+ const tokens = usage ? usage.input + usage.output : 0;
177
+ const tools = toolCalls?.length ?? 0;
178
+ const base = `${tokens} tok · ${tools} tool calls`;
179
+ return elapsed ? `${base} · ${elapsed}` : base;
180
+ }
181
+
182
+ /**
183
+ * renderResult 的文本兜底:从 result.content[0] 提取纯文本。
184
+ * 多处 tool 的 renderResult 曾各自内联此逻辑,提取后统一调用。
185
+ */
186
+ export function renderTextFallback(
187
+ result: { content?: Array<{ type: string; text?: string }> },
188
+ ): string {
189
+ const first = result.content?.[0];
190
+ return first?.type === "text" ? (first.text ?? "") : "";
191
+ }
192
+
193
+ /** Format a single activity line: ToolName(argsPreview). */
194
+ export function formatActivityLine(entry: ToolCallEntry, maxWidth: number): string {
195
+ // 语义阈值与开销:低于此宽度只显名称;括号占 2 字符 (name)。
196
+ const MIN_ACTIVITY_WIDTH = 10;
197
+ const PARENS_OVERHEAD = 2;
198
+ if (maxWidth < MIN_ACTIVITY_WIDTH) return entry.name;
199
+ const argsBudget = maxWidth - entry.name.length - PARENS_OVERHEAD;
200
+ if (argsBudget <= 0) return truncateToWidth(entry.name, maxWidth);
201
+ const truncated = entry.input.length > argsBudget
202
+ ? entry.input.slice(0, argsBudget - 1) + ELLIPSIS
203
+ : entry.input;
204
+ return `${entry.name}(${truncated})`;
205
+ }
206
+
207
+ // ── ANSI helpers ──────────────────────────────────────────────
208
+
209
+ /** Measure visible width of a string (strips ANSI escapes, handles CJK/emoji).
210
+ * Delegates to pi-tui's visibleWidth for accurate width calculation.
211
+ */
212
+ export function visibleLen(s: string): number {
213
+ return visibleWidth(s);
214
+ }
215
+
216
+ /** Pad an ANSI-escaped string to a target *visible* width.
217
+ * 只 pad 不截断:超宽时原样返回(调用方负责先截断)。
218
+ * 对齐 subagents 的 padToVisible 语义。
219
+ */
220
+ export function padVisible(s: string, width: number): string {
221
+ const vl = visibleLen(s);
222
+ if (vl >= width) return s;
223
+ return s + " ".repeat(width - vl);
224
+ }
225
+
226
+ /**
227
+ * 分段着色版填充:title 和 fill 都已着色(含 ANSI),拼接时各自 ANSI 延续。
228
+ * 解决 ANSI 嵌套失色:若用 fg("c1", fill(title, "─", n)),
229
+ * title 内的 \x1b[0m 会重置外层 c1,导致 title 之后的 ─ 失去 c1。
230
+ * 改成 title + fill.repeat(后),fill 整段保持自己的 ANSI,不依赖外层包裹。
231
+ *
232
+ * 对齐 subagents format.ts segFillColored(同源移植)。
233
+ */
234
+ export function segFillColored(
235
+ titleStyled: string | undefined,
236
+ fillStyled: string,
237
+ width: number,
238
+ ): string {
239
+ if (width <= 0) return "";
240
+ const fillW = visibleLen(fillStyled);
241
+ if (!titleStyled || fillW === 0) {
242
+ return fillStyled.repeat(width);
243
+ }
244
+ const tw = visibleLen(titleStyled);
245
+ if (tw >= width) return truncateToWidth(titleStyled, width);
246
+ const fillCount = width - tw;
247
+ return titleStyled + fillStyled.repeat(fillCount);
248
+ }
249
+
250
+ // ── Phase group (filters empty phases) ────────────────────────
251
+
252
+ export interface PhaseGroup {
253
+ name: string;
254
+ nodes: ExecutionTraceNode[];
255
+ doneCount: number;
256
+ }
257
+
258
+ /** The fallback phase name when node has no explicit phase. */
259
+ const NO_PHASE = "(default)";
260
+
261
+ /** Build phase groups. Nodes without a phase are placed in an unnamed group. */
262
+ export function buildPhaseGroups(nodes: ExecutionTraceNode[]): PhaseGroup[] {
263
+ const map = groupByPhase(nodes);
264
+ const result: PhaseGroup[] = [];
265
+ for (const [name, phaseNodes] of map) {
266
+ if (phaseNodes.length > 0) {
267
+ result.push({
268
+ name: name === NO_PHASE ? "" : name,
269
+ nodes: phaseNodes,
270
+ doneCount: phaseNodes.filter((n) => n.status === "completed").length,
271
+ });
272
+ }
273
+ }
274
+ return result;
275
+ }
276
+
277
+ // ── Sidebar phase line formatter ─────────────────────────────
278
+
279
+ export function formatPhaseLine(
280
+ pg: PhaseGroup,
281
+ idx: number,
282
+ isSelected: boolean,
283
+ theme: ThemeLike,
284
+ maxWidth: number,
285
+ ): string {
286
+ const pointer = isSelected ? "❯ " : " ";
287
+ const dot = statusDotStr(pg.doneCount === pg.nodes.length ? "completed" : "running", theme);
288
+ const name = pg.name || "(unnamed)";
289
+ const label = `${idx + 1} ${name} ${pg.doneCount}/${pg.nodes.length}`;
290
+ // pointer(2) + dot(1) + space(1)
291
+ const PHASE_PREFIX_WIDTH = 4;
292
+ const budget = maxWidth - PHASE_PREFIX_WIDTH;
293
+ const truncated = visibleLen(label) > budget
294
+ ? truncateToWidth(label, budget - 1) + ELLIPSIS
295
+ : label;
296
+ return `${pointer}${dot} ${truncated}`;
297
+ }
298
+
299
+ // ── Agent one-liner for overview right panel ──────────────────
300
+
301
+ const TOKEN_K = 1000;
302
+
303
+ export function formatAgentOneLiner(node: ExecutionTraceNode, theme: ThemeLike): string {
304
+ const dot = statusDotStr(node.status, theme);
305
+ const elapsed = formatElapsed(
306
+ node.startedAt,
307
+ node.completedAt ? new Date(node.completedAt).getTime() : Date.now(),
308
+ );
309
+ const tok = node.result?.usage;
310
+ const tokStr = tok
311
+ ? `${Math.round((tok.input + tok.output) / TOKEN_K)}k tok`
312
+ : "";
313
+ const tcCount = node.result?.toolCalls?.length ?? 0;
314
+ const parts = [dot, node.agent, node.model];
315
+ if (tokStr) parts.push(`${tokStr} · ${tcCount} tools`);
316
+ parts.push(elapsed);
317
+ return parts.join(" ");
318
+ }
319
+
320
+