@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,944 @@
1
+ /**
2
+ * Workflow Fullscreen TUI View — Three-level navigation.
3
+ *
4
+ * Level 0 (Phase): 左 phase list,右 agent overview
5
+ * Level 1 (Agent): 左 agent list,右 agent summary
6
+ * Level 2 (Detail): 完整 agent 执行详情
7
+ *
8
+ * 读 WorkflowRun 聚合根;移除 restart(D-9)。新 engine 无 orchestrator 事件层
9
+ * (AC-3),view 改用内部轮询(setInterval TICK_MS)从 run.state.trace 实时读 +
10
+ * requestRender。
11
+ *
12
+ * SDK 集成:ctx.ui.custom factory 返回 Component{render(width), handleInput(data),
13
+ * invalidate},第二参数 `{overlay:true, overlayOptions}`(全屏 overlay,对齐 main +
14
+ * subagents 扩展 + docs/pi-tui-development-guide.md §3.2)。按键经
15
+ * matchesKey(data, KeyId) 解析(兼容 xterm/iTerm/kitty 转义序列差异)。
16
+ * escape/ctrl+c 在 keybindings 同映射到 exit。
17
+ *
18
+ * 关键实现点:(a) overlay 第二参数必须传,否则 view 不全屏;(b) trace 必须 per-render
19
+ * 重读(run.state.trace.toArray 返回内部数组引用,trace.append 后下次 render 可见),
20
+ * 配 1s tick invalidate + requestRender 保证运行中刷新;(c) 's' save 模式无条件可用
21
+ * (saveWorkflow 内部对非 tmp workflow 返回错误消息);(d) pause/resume/abort 失败时
22
+ * notify 反馈,不静默吞。
23
+ */
24
+
25
+ import { promises as fsPromises } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join as pathJoin } from "node:path";
28
+
29
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
30
+ import { Key, matchesKey } from "@mariozechner/pi-tui";
31
+
32
+ import {
33
+ getAllToolCalls,
34
+ projectLiveProgress,
35
+ } from "../../execution/execution-record.ts";
36
+ import type { ExecutionTraceNode } from "../../orchestration/models/types.ts";
37
+ import type { WorkflowRun } from "../../orchestration/models/workflow-run.ts";
38
+ import { saveWorkflow } from "../../orchestration/workflow-files.ts";
39
+ import {
40
+ BOX_BORDER_CHARS,
41
+ BUDGET_TOKENS_DIVISOR,
42
+ buildPhaseGroups,
43
+ ELLIPSIS,
44
+ formatActivityLine,
45
+ formatAgentOneLiner,
46
+ formatElapsed,
47
+ formatElapsedSeconds,
48
+ formatPhaseLine,
49
+ formatStatusBadge,
50
+ padVisible,
51
+ SIDEBAR_WIDTH,
52
+ statusDotStr,
53
+ TERM_ROWS_FALLBACK,
54
+ type ThemeLike,
55
+ visibleLen,
56
+ } from "./format.ts";
57
+
58
+ // L2 详情内容构建 + 滚动按键(纯函数)抽到 detail-content.ts;此处 re-export 保持
59
+ // view 的对外 API 不变(测试仍从 WorkflowsView 导入)。
60
+ export {
61
+ buildDetailContent,
62
+ detailContentLength,
63
+ type DetailKeyResult,
64
+ type DetailScrollContext,
65
+ processDetailKey,
66
+ } from "./detail-content.ts";
67
+ import { buildDetailContent, detailContentLength, type DetailScrollContext,processDetailKey } from "./detail-content.ts";
68
+
69
+ // ── TUI layout constants ──────────────────────────────────────
70
+
71
+ const NAV_LEVEL_DETAIL = 2;
72
+ type NavLevel = 0 | 1 | typeof NAV_LEVEL_DETAIL;
73
+ const MIN_BODY_LINES = 3;
74
+ const BODY_HEIGHT_NUMERATOR = 2;
75
+ const BODY_HEIGHT_DENOMINATOR = 3;
76
+ /** save overlay 居中计算的除数(÷2 居中)。 */
77
+ const OVERLAY_CENTER_DIVISOR = 2;
78
+
79
+ /** 轮询间隔:engine 无事件推送,view 自轮询 trace 变化。
80
+ * 200ms 对齐 subagents spinner 节奏,保证 agent 运行中 live 进度(tool calls/activity)流式可见。 */
81
+ const TICK_MS = 200;
82
+
83
+ /** 可打印 ASCII 字符下限(用于 save overlay 输入过滤)。 */
84
+ const PRINTABLE_CHAR_MIN = 32;
85
+
86
+ // ── 边框着色 helper(统一 borderMuted,避 ANSI 嵌套失色)──────────
87
+ // 对齐 subagents list-component.ts 的 b/dash/dashes/titleBorder/plainBorder/walled。
88
+ // 所有 ╭╮╰╯├┤┬┴─│ 统一走 borderMuted token,保证边框颜色一致。
89
+
90
+ /** 着色单个框线字符(borderMuted)。 */
91
+ function b(theme: ThemeLike, s: string): string {
92
+ return theme.fg("borderMuted", s);
93
+ }
94
+ /** 着色单字符填充用的 ─(供 segFillColored 的 fillStyled)。 */
95
+ function dash(theme: ThemeLike): string {
96
+ return theme.fg("borderMuted", "─");
97
+ }
98
+ /** 满宽 ─ 填充串(borderMuted)。n 次单字符着色,ANSI 自然延续。 */
99
+ function dashes(theme: ThemeLike, n: number): string {
100
+ return dash(theme).repeat(Math.max(0, n));
101
+ }
102
+ /** 纯线顶/底框(无标题):左角 + ─×W + 右角。 */
103
+ function plainBorder(theme: ThemeLike, left: string, right: string, contentWidth: number): string {
104
+ return b(theme, left) + dashes(theme, contentWidth) + b(theme, right);
105
+ }
106
+ /** 内容行墙:│ + 内容(pad 到 contentWidth) + │,墙字符 borderMuted。 */
107
+ function walled(theme: ThemeLike, content: string, contentWidth: number): string {
108
+ return `${b(theme, "│")}${padVisible(content, contentWidth)}${b(theme, "│")}`;
109
+ }
110
+
111
+ // ── Minimal TUI duck-types(避免直接 import TUI/KeybindingsManager 类型 ──
112
+ // 共享类型 fallback shared/types/mariozechner/index.d.ts 不导出 TUI 类,
113
+ // workspace 跨包 typecheck 会报 "no exported member 'TUI'"。
114
+ // 此处用结构化接口替代——只声明 view 实际用到的成员(requestRender + terminal)。
115
+
116
+ interface TuiLike {
117
+ terminal: { columns: number; rows: number };
118
+ requestRender(): void;
119
+ }
120
+
121
+ // ── View actions ──────────────────────────────────────────────
122
+
123
+ /**
124
+ * view 可触发的 lifecycle 操作(由调用方注入,避免 view 直接依赖 Engine 函数)。
125
+ * 每个 action 接收 runId;调用方绑到 pauseRun/resumeRun/abortRun。
126
+ */
127
+ export interface ViewActions {
128
+ pause: (runId: string) => Promise<void>;
129
+ resume: (runId: string) => Promise<void>;
130
+ abort: (runId: string) => Promise<void>;
131
+ }
132
+
133
+ // ── View state ────────────────────────────────────────────────
134
+
135
+ interface ViewState {
136
+ level: NavLevel;
137
+ phaseIdx: number;
138
+ agentIdx: number;
139
+ promptExpanded: boolean;
140
+ disposed: boolean;
141
+ // ── Save mode ──
142
+ saveMode: boolean;
143
+ saveInputValue: string;
144
+ saveMessage: string;
145
+ saveMsgOk: boolean;
146
+ // ── L0/L1 列表滚动 ──
147
+ /** 左侧 phase list 滚动 offset。 */
148
+ phaseScrollOffset: number;
149
+ /** 右侧 agent list 滚动 offset。 */
150
+ agentScrollOffset: number;
151
+ // ── L2 详情滚动 ──
152
+ /** 右侧 detail 当前滚动 offset(render 路径 clamp 收敛)。 */
153
+ detailScrollOffset: number;
154
+ /** running 态是否自动钉底部(用户 PgUp 后置 false,End/PgDn 到底恢复 true)。 */
155
+ followTail: boolean;
156
+ }
157
+
158
+ function createInitialState(): ViewState {
159
+ return {
160
+ level: 0,
161
+ phaseIdx: 0,
162
+ agentIdx: 0,
163
+ promptExpanded: false,
164
+ disposed: false,
165
+ saveMode: false,
166
+ saveInputValue: "",
167
+ saveMessage: "",
168
+ saveMsgOk: false,
169
+ phaseScrollOffset: 0,
170
+ agentScrollOffset: 0,
171
+ detailScrollOffset: 0,
172
+ followTail: true, // 默认钉底(对齐 subagents 进详情即底部对齐)
173
+ };
174
+ }
175
+
176
+ // ── View factory ──────────────────────────────────────────────
177
+
178
+ /**
179
+ * 创建 workflow fullscreen view。
180
+ *
181
+ * @param run WorkflowRun 聚合根(读 state.status/spec/trace/meta)
182
+ * @param theme ThemeLike(避免直接 import Pi runtime)
183
+ * @param ctx ExtensionContext(调 ui.custom 渲染 + ui.notify 错误反馈)
184
+ * @param actions lifecycle 操作(pause/resume/abort),由调用方注入
185
+ */
186
+ export function createWorkflowsView(
187
+ run: WorkflowRun,
188
+ theme: ThemeLike,
189
+ ctx: ExtensionContext,
190
+ actions: ViewActions,
191
+ ): Promise<void> {
192
+ return ctx.ui.custom<void>((_tui: unknown, _t: unknown, _kb: unknown, done: (result: void) => void) => {
193
+ const state = createInitialState();
194
+ const tui = _tui as TuiLike;
195
+
196
+ function currentPhaseAgents() {
197
+ const live = buildPhaseGroups([...run.state.trace.toArray()]);
198
+ const pg = live[state.phaseIdx];
199
+ return pg ? pg.nodes : [];
200
+ }
201
+
202
+ function clampSelections() {
203
+ const live = buildPhaseGroups([...run.state.trace.toArray()]);
204
+ if (state.phaseIdx >= live.length) state.phaseIdx = Math.max(0, live.length - 1);
205
+ const agents = currentPhaseAgents();
206
+ if (state.agentIdx >= agents.length) state.agentIdx = Math.max(0, agents.length - 1);
207
+ }
208
+
209
+ // 渲染缓存:缓存 key = width×rows,终端 resize 改高度时缓存失效(防行数不匹配终端)
210
+ const cache = { key: undefined as string | undefined, lines: undefined as string[] | undefined };
211
+ const requestRender = () => tui.requestRender();
212
+
213
+ // ── 轮询 tick:engine 无事件推送,view 自轮询 trace 变化 ──
214
+ // 每 200ms 重绘,保证 header 动态数据(elapsed/tokens)实时更新。
215
+ // 行数固定后,diff-redraw 引擎能正确逐行对比,不会出现残影。
216
+ const tick = setInterval(() => {
217
+ if (state.disposed) return;
218
+ cache.key = undefined;
219
+ cache.lines = undefined;
220
+ requestRender();
221
+ }, TICK_MS);
222
+
223
+ const wrappedDone = () => {
224
+ if (state.disposed) return;
225
+ state.disposed = true;
226
+ clearInterval(tick);
227
+ done();
228
+ };
229
+
230
+ // ── L2 详情滚动辅助 ──
231
+ /** 安全读 terminal.rows(duck-type 失败兜底,对齐 subagents termRows)。 */
232
+ function termRows(): number {
233
+ const rows = tui.terminal?.rows;
234
+ return typeof rows === "number" && rows > 0 ? rows : TERM_ROWS_FALLBACK;
235
+ }
236
+ /** L2 右侧 detail viewport 高度(与 renderLayout 的 viewH 同源)。 */
237
+ function detailViewportHeight(): number {
238
+ return Math.max(MIN_BODY_LINES, bodyHeight(termRows()));
239
+ }
240
+ /** 重置 detail 滚动到底部对齐(切 agent / 进 L2 时调用)。 */
241
+ function resetDetailScroll(): void {
242
+ state.detailScrollOffset = Number.MAX_SAFE_INTEGER; // render clamp 收敛到 max
243
+ state.followTail = true;
244
+ }
245
+
246
+ // ── Key handling(SDK Component.handleInput 模式,对齐 main) ──
247
+ // matchesKey(data, KeyId) 处理终端转义序列差异(xterm/iTerm/kitty),
248
+ // 优于手写 \x1b[A 等原始序列。escape/ctrl+c 在 keybindings 里同映射到 exit。
249
+ function handleInput(data: string): void {
250
+ if (state.disposed) return;
251
+
252
+ // ── Save mode 拦截(缺陷 #3 恢复)── save overlay 活跃时,所有键走 save 流程
253
+ if (state.saveMode) {
254
+ handleSaveModeInput(data);
255
+ return;
256
+ }
257
+
258
+ // Escape / ctrl+c: level back or exit
259
+ if (matchesKey(data, Key.escape)) {
260
+ if (state.level === 0) {
261
+ wrappedDone();
262
+ return;
263
+ }
264
+ state.level = (state.level - 1) as NavLevel;
265
+ state.promptExpanded = false;
266
+ cache.key = undefined;
267
+ requestRender();
268
+ return;
269
+ }
270
+
271
+ // ── L2 详情滚动(PgUp/PgDn/Home/End,对齐 subagents processKey 阶段 2) ──
272
+ // up/down 在 L2 用于切 agent,故滚动键独立于此;未命中则落到下面的 up/down/enter。
273
+ if (state.level === NAV_LEVEL_DETAIL) {
274
+ const node = currentPhaseAgents()[state.agentIdx];
275
+ if (node) {
276
+ const detailCtx: DetailScrollContext = {
277
+ viewportHeight: detailViewportHeight(),
278
+ contentLines: detailContentLength(node, state, run, theme),
279
+ isRunning: node.status === "running",
280
+ };
281
+ const r = processDetailKey(
282
+ data,
283
+ { scrollOffset: state.detailScrollOffset, followTail: state.followTail },
284
+ detailCtx,
285
+ );
286
+ if (r.handled) {
287
+ state.detailScrollOffset = r.scrollOffset;
288
+ state.followTail = r.followTail;
289
+ cache.key = undefined;
290
+ requestRender();
291
+ return;
292
+ }
293
+ }
294
+ }
295
+
296
+ // Navigation: up/down
297
+ if (matchesKey(data, Key.up)) {
298
+ if (state.level === 0 && state.phaseIdx > 0) {
299
+ state.phaseIdx--;
300
+ state.agentIdx = 0;
301
+ state.phaseScrollOffset = 0; // 切 phase → 重置滚动
302
+ state.agentScrollOffset = 0; // 切 phase → 重置滚动
303
+ } else if (state.level === 1 && state.agentIdx > 0) {
304
+ state.agentIdx--;
305
+ // agentScrollOffset 由 renderLevel1 自动调整
306
+ } else if (state.level === NAV_LEVEL_DETAIL && state.agentIdx > 0) {
307
+ state.agentIdx--;
308
+ state.promptExpanded = false;
309
+ resetDetailScroll(); // 切 agent → 底部对齐(对齐 subagents 进详情即钉底)
310
+ }
311
+ cache.key = undefined;
312
+ requestRender();
313
+ return;
314
+ }
315
+ if (matchesKey(data, Key.down)) {
316
+ if (state.level === 0) {
317
+ const live = buildPhaseGroups([...run.state.trace.toArray()]);
318
+ if (state.phaseIdx < live.length - 1) {
319
+ state.phaseIdx++;
320
+ state.agentIdx = 0;
321
+ state.phaseScrollOffset = 0; // 切 phase → 重置滚动
322
+ state.agentScrollOffset = 0; // 切 phase → 重置滚动
323
+ }
324
+ } else if (state.level === 1) {
325
+ const agents = currentPhaseAgents();
326
+ if (state.agentIdx < agents.length - 1) state.agentIdx++;
327
+ // agentScrollOffset 由 renderLevel1 自动调整
328
+ } else if (state.level === NAV_LEVEL_DETAIL) {
329
+ const agents = currentPhaseAgents();
330
+ if (state.agentIdx < agents.length - 1) {
331
+ state.agentIdx++;
332
+ state.promptExpanded = false;
333
+ resetDetailScroll(); // 切 agent → 底部对齐
334
+ }
335
+ }
336
+ cache.key = undefined;
337
+ requestRender();
338
+ return;
339
+ }
340
+
341
+ // Enter: drill down (L0→L1→L2) or toggle prompt (L2)
342
+ if (matchesKey(data, Key.enter)) {
343
+ if (state.level === 0 && currentPhaseAgents().length > 0) {
344
+ state.level = 1;
345
+ state.agentIdx = 0;
346
+ state.agentScrollOffset = 0; // 进 L1 → 重置滚动
347
+ } else if (state.level === 1) {
348
+ state.level = NAV_LEVEL_DETAIL;
349
+ state.promptExpanded = false;
350
+ resetDetailScroll(); // 进 L2 → 底部对齐
351
+ } else if (state.level === NAV_LEVEL_DETAIL) {
352
+ state.promptExpanded = !state.promptExpanded;
353
+ // 展开/折叠改变内容长度,render 路径 clamp 收敛;保持当前锚点语义
354
+ }
355
+ cache.key = undefined;
356
+ requestRender();
357
+ return;
358
+ }
359
+
360
+ // ── Lifecycle shortcuts (no restart per D-9) ──
361
+ if (data === "p") {
362
+ if (run.state.status === "running") {
363
+ void actions.pause(run.runId)
364
+ .then(() => { cache.key = undefined; requestRender(); })
365
+ .catch((err: Error) => ctx.ui.notify(`Pause failed: ${err.message}`, "error"));
366
+ } else if (run.state.status === "paused") {
367
+ void actions.resume(run.runId)
368
+ .then(() => { cache.key = undefined; requestRender(); })
369
+ .catch((err: Error) => ctx.ui.notify(`Resume failed: ${err.message}`, "error"));
370
+ }
371
+ return;
372
+ }
373
+ if (data === "a") {
374
+ if (run.state.status === "running" || run.state.status === "paused") {
375
+ void actions.abort(run.runId)
376
+ .then(() => { cache.key = undefined; requestRender(); })
377
+ .catch((err: Error) => ctx.ui.notify(`Abort failed: ${err.message}`, "error"));
378
+ }
379
+ return;
380
+ }
381
+
382
+ // ── Save shortcut(对齐 main:总是进入 save mode,非 tmp 时 saveWorkflow 报错) ──
383
+ if (data === "s") {
384
+ state.saveMode = true;
385
+ state.saveInputValue = run.spec.scriptName;
386
+ state.saveMessage = "";
387
+ state.saveMsgOk = false;
388
+ cache.key = undefined;
389
+ requestRender();
390
+ return;
391
+ }
392
+
393
+ // ── Trace export(对齐 main 的 S 键):导出完整 trace 到 Markdown 文件 ──
394
+ if (data === "S") {
395
+ saveTraceToFile(run, ctx);
396
+ return;
397
+ }
398
+ }
399
+
400
+ /** save overlay 内的按键处理(esc 取消 / enter 保存 / backspace 删除 / 可打印追加)。 */
401
+ function handleSaveModeInput(data: string): void {
402
+ // Escape → 退出 save 模式
403
+ if (matchesKey(data, Key.escape)) {
404
+ state.saveMode = false;
405
+ cache.key = undefined;
406
+ requestRender();
407
+ return;
408
+ }
409
+ // Enter → 保存
410
+ if (data === "\r" || data === "\n") {
411
+ const name = state.saveInputValue.trim();
412
+ if (!name) {
413
+ state.saveMessage = "Please enter a name";
414
+ state.saveMsgOk = false;
415
+ cache.key = undefined;
416
+ requestRender();
417
+ return;
418
+ }
419
+ void saveWorkflow(run.spec.scriptName, name)
420
+ .then((msg) => {
421
+ state.saveMessage = msg;
422
+ state.saveMsgOk = true;
423
+ state.saveMode = false;
424
+ cache.key = undefined;
425
+ requestRender();
426
+ })
427
+ .catch((err: Error) => {
428
+ state.saveMessage = err.message;
429
+ state.saveMsgOk = false;
430
+ cache.key = undefined;
431
+ requestRender();
432
+ });
433
+ return;
434
+ }
435
+ // Backspace → 删除最后一个字符
436
+ if (data === "\x7f" || data === "\b") {
437
+ state.saveMessage = "";
438
+ if (state.saveInputValue.length > 0) {
439
+ state.saveInputValue = state.saveInputValue.slice(0, -1);
440
+ }
441
+ cache.key = undefined;
442
+ requestRender();
443
+ return;
444
+ }
445
+ // 可打印字符 → 追加
446
+ if (data.length === 1 && data.charCodeAt(0) >= PRINTABLE_CHAR_MIN) {
447
+ state.saveMessage = "";
448
+ state.saveInputValue += data;
449
+ cache.key = undefined;
450
+ requestRender();
451
+ return;
452
+ }
453
+ // 屏蔽其他键(↑↓ 等)
454
+ }
455
+
456
+ // ── Component(SDK 规范:render(width) → string[] + handleInput + invalidate) ──
457
+ return {
458
+ invalidate(): void {
459
+ cache.key = undefined;
460
+ cache.lines = undefined;
461
+ },
462
+ render(width: number): string[] {
463
+ const height = tui.terminal.rows;
464
+ const key = `${width}x${height}`;
465
+ if (cache.lines && cache.key === key) return cache.lines;
466
+ clampSelections();
467
+ // 缺陷 #1 修复:每次 render 从 run.state.trace 实时读(toArray 返回内部数组引用,
468
+ // 后续 trace.append 会反映到 view),不再用 factory 时的冻结快照。
469
+ const liveGroups = buildPhaseGroups([...run.state.trace.toArray()]);
470
+ const raw = renderLayout(run, state, liveGroups, theme, width, height);
471
+ // Pad to terminal height so the overlay fills the screen (matches main 行为)
472
+ const lines = raw.length < height
473
+ ? [...raw, ...Array.from({ length: height - raw.length }, () => "")]
474
+ : raw;
475
+ cache.key = key;
476
+ cache.lines = lines;
477
+ return lines;
478
+ },
479
+ handleInput,
480
+ };
481
+ }, {
482
+ // 缺陷 #2 修复:overlay 第二参数(对齐 main + subagents + pi-tui guide §3.2)
483
+ overlay: true,
484
+ overlayOptions: { anchor: "center" as const, width: "100%", maxHeight: "100%", margin: 0 },
485
+ });
486
+ }
487
+
488
+ // ── Layout rendering(box-drawing 框架,对齐 main)──────────────
489
+ //
490
+ // 视觉结构(与 main 一致):
491
+ // ╭───────────────────────────────────────────────────╮
492
+ // │ name (bold) ● status · x/y · Ns │ ← header
493
+ // ├───────────────────────────────────────────────────┤
494
+ // │ Phases │ title · N agents │
495
+ // │ ──────────────── │ ────────────────── │ ← body
496
+ // │ ❯ ● 1 build 0/2 │ ● builder model ... │
497
+ // │ ● 2 deploy 0/1 │ ● tester model ... │
498
+ // │ (pad) │ (pad) │
499
+ // ╰───────────────────────────────────────────────────╯
500
+ // ↑↓ phase · ⏎ enter · p pause · a abort · s save · esc back ← footer (框外)
501
+ //
502
+ // body = sidebar(SIDEBAR_WIDTH) │ main(rest)
503
+ // save overlay 活跃时居中覆盖 body。
504
+
505
+ function bodyHeight(screenHeight: number): number {
506
+ // header(2: name+desc/blank) + border(1) + body + border(1) + footer(1)
507
+ const HEADER_FOOTER_LINES = 6;
508
+ return Math.max(MIN_BODY_LINES, Math.floor((screenHeight * BODY_HEIGHT_NUMERATOR) / BODY_HEIGHT_DENOMINATOR) - HEADER_FOOTER_LINES);
509
+ }
510
+
511
+ const BUDGET_COST_DECIMALS = 4;
512
+
513
+ function renderLayout(
514
+ run: WorkflowRun,
515
+ state: ViewState,
516
+ phaseGroups: ReturnType<typeof buildPhaseGroups>,
517
+ theme: ThemeLike,
518
+ screenWidth: number,
519
+ screenHeight: number,
520
+ ): string[] {
521
+ const lines: string[] = [];
522
+ const contentWidth = screenWidth - BOX_BORDER_CHARS;
523
+ const mainWidth = contentWidth - SIDEBAR_WIDTH - 1; // -1 for the │ divider
524
+
525
+ renderHeader(lines, run, theme, contentWidth);
526
+
527
+ const phase = phaseGroups[state.phaseIdx] ?? phaseGroups[0];
528
+ const agents = phase?.nodes ?? [];
529
+ const now = Date.now();
530
+
531
+ // 固定 body 高度,确保 renderLayout 始终返回固定行数
532
+ const viewH = Math.max(MIN_BODY_LINES, bodyHeight(screenHeight));
533
+
534
+ const bodyStart = lines.length;
535
+ if (state.level === 0) {
536
+ renderLevel0(lines, run, phaseGroups, state, theme, mainWidth, now, viewH);
537
+ } else if (state.level === 1) {
538
+ renderLevel1(lines, run, phaseGroups, state, theme, mainWidth, now, viewH);
539
+ } else {
540
+ // L2 详情走固定高度 viewport(右侧滚动),高度 = minBody(与 L0/L1 最小一致)
541
+ renderLevel2(lines, run, agents, state, theme, mainWidth, now, viewH);
542
+ }
543
+
544
+ // Padding 已在各 renderLevel 内部处理,无需额外 padding
545
+
546
+ // Wrap body lines with │ borders + sidebar divider(统一 borderMuted)
547
+ for (let i = bodyStart; i < lines.length; i++) {
548
+ lines[i] = walled(theme, lines[i], contentWidth);
549
+ }
550
+
551
+ lines.push(plainBorder(theme, "╰", "╯", contentWidth));
552
+
553
+ // Save overlay(缺陷 #3):居中覆盖 body
554
+ if (state.saveMode) {
555
+ const overlayLines = renderSaveOverlay(state, theme, screenWidth);
556
+ const overlayStart = Math.max(bodyStart, bodyStart + Math.floor((lines.length - bodyStart - overlayLines.length) / OVERLAY_CENTER_DIVISOR));
557
+ for (let i = 0; i < overlayLines.length && overlayStart + i < lines.length; i++) {
558
+ lines[overlayStart + i] = overlayLines[i];
559
+ }
560
+ }
561
+
562
+ // Footer(框外,对齐 main renderFooter)
563
+ renderFooter(lines, run, state, theme);
564
+ return lines;
565
+ }
566
+
567
+ /** Header:╭─╮ + name(bold) + 右侧 status/agents/elapsed/budget。 */
568
+ function renderHeader(
569
+ lines: string[],
570
+ run: WorkflowRun,
571
+ theme: ThemeLike,
572
+ contentWidth: number,
573
+ ): void {
574
+ const traceArr = run.state.trace.toArray();
575
+ const completed = traceArr.filter((n) => n.status === "completed").length;
576
+ const total = traceArr.length;
577
+ const elapsed = formatElapsed(run.meta.startedAt);
578
+ const headerRight = `${formatStatusBadge(run.state.status, theme)} · ${completed}/${total} agents · ${elapsed}`;
579
+ const budget = run.state.budget;
580
+ const budgetStr = `${Math.round(budget.usedTokens / BUDGET_TOKENS_DIVISOR)}k/${budget.maxTokens ? `${Math.round(budget.maxTokens / BUDGET_TOKENS_DIVISOR)}k` : "∞"} tok · $${budget.usedCost.toFixed(BUDGET_COST_DECIMALS)}`;
581
+
582
+ const nameLine = theme.bold(run.spec.scriptName);
583
+ const rightPart = theme.fg("muted", `${headerRight} · ${budgetStr}`);
584
+
585
+ lines.push(plainBorder(theme, "╭", "╮", contentWidth));
586
+ lines.push(walled(theme, nameLine, contentWidth));
587
+
588
+ if (run.spec.description) {
589
+ const maxDesc = contentWidth - visibleLen(rightPart) - 1;
590
+ const descText = run.spec.description.length > maxDesc
591
+ ? run.spec.description.slice(0, maxDesc - 1) + ELLIPSIS
592
+ : run.spec.description;
593
+ const descPart = theme.fg("dim", descText);
594
+ const padLen = Math.max(0, contentWidth - visibleLen(descPart) - visibleLen(rightPart));
595
+ lines.push(`${b(theme, "│")}${descPart}${" ".repeat(padLen)}${rightPart}${b(theme, "│")}`);
596
+ } else {
597
+ lines.push(walled(theme, rightPart, contentWidth));
598
+ }
599
+ lines.push(plainBorder(theme, "├", "┤", contentWidth));
600
+ }
601
+
602
+ /** Footer(框外):nav hint + lifecycle shortcuts + esc/ctrl+c。 */
603
+ function renderFooter(
604
+ lines: string[],
605
+ run: WorkflowRun,
606
+ state: ViewState,
607
+ theme: ThemeLike,
608
+ ): void {
609
+ const navPart = state.level === 0
610
+ ? "↑↓ phase · ⏎ enter"
611
+ : state.level === 1
612
+ ? "↑↓ agent · ⏎ detail"
613
+ : "↑↓ agent · ⏎ prompt · PgUp/PgDn scroll";
614
+ const actionParts: string[] = [];
615
+ const status = run.state.status;
616
+ if (status === "running" || status === "paused") {
617
+ actionParts.push("a abort");
618
+ actionParts.push(status === "paused" ? "p resume" : "p pause");
619
+ }
620
+ actionParts.push("s save");
621
+ actionParts.push("S trace");
622
+ actionParts.push("esc back");
623
+ const footer = `${navPart} · ${actionParts.join(" · ")}`;
624
+ lines.push("");
625
+ lines.push(theme.fg("muted", footer));
626
+ }
627
+
628
+ /** mergeBody:左侧 sidebar + │ divider + 右侧 main,拼成 body 行(divider 着色 borderMuted)。 */
629
+ function mergeBody(
630
+ lines: string[],
631
+ leftLines: string[],
632
+ rightLines: string[],
633
+ theme: ThemeLike,
634
+ ): void {
635
+ const bodyHeightVal = Math.max(leftLines.length, rightLines.length);
636
+ for (let i = 0; i < bodyHeightVal; i++) {
637
+ const left = padVisible(leftLines[i] ?? "", SIDEBAR_WIDTH);
638
+ lines.push(left + b(theme, "│") + (rightLines[i] ?? ""));
639
+ }
640
+ }
641
+
642
+ // 分屏视图固定头部行数(title + separator = 2)。
643
+ const SPLIT_HEADER_LINES = 2;
644
+
645
+ /** 计算滚动视口起始 index,确保选中项可见(居中策略,参考 subagents renderLeftColumn)。
646
+ * 返回 [startIdx, viewportH]:startIdx 是内容区第一个可见项的 index,viewportH 是内容区可显示行数。 */
647
+ function computeViewport(
648
+ totalCount: number,
649
+ selectedIdx: number,
650
+ bodyH: number,
651
+ ): { startIdx: number; viewportH: number } {
652
+ const viewportH = Math.max(0, bodyH - SPLIT_HEADER_LINES);
653
+ if (viewportH <= 0) return { startIdx: 0, viewportH: 0 };
654
+ if (totalCount <= viewportH) return { startIdx: 0, viewportH };
655
+ // 选中项居中,到列表顶/底贴边
656
+ const maxStart = Math.max(0, totalCount - viewportH);
657
+ const center = Math.floor(selectedIdx - viewportH / OVERLAY_CENTER_DIVISOR);
658
+ return { startIdx: Math.max(0, Math.min(center, maxStart)), viewportH };
659
+ }
660
+
661
+ // ── Level 0: Phase selection ──────────────────────────────────
662
+
663
+ function renderLevel0(
664
+ lines: string[],
665
+ run: WorkflowRun,
666
+ phases: ReturnType<typeof buildPhaseGroups>,
667
+ state: ViewState,
668
+ theme: ThemeLike,
669
+ mainWidth: number,
670
+ now: number,
671
+ bodyH: number,
672
+ ): void {
673
+ const leftLines: string[] = [];
674
+ const rightLines: string[] = [];
675
+
676
+ // Left: sidebar title + phase list(固定高度 viewport + 滚动,只构建可见行)
677
+ leftLines.push(theme.fg("muted", "Phases"));
678
+ leftLines.push(dashes(theme, SIDEBAR_WIDTH));
679
+ const { startIdx: phaseStart, viewportH: phaseViewportH } = computeViewport(phases.length, state.phaseIdx, bodyH);
680
+ for (let i = phaseStart; i < phaseStart + phaseViewportH && i < phases.length; i++) {
681
+ leftLines.push(formatPhaseLine(phases[i], i, i === state.phaseIdx, theme, SIDEBAR_WIDTH));
682
+ }
683
+ // 回写滚动偏移(clamp,防止状态脏值)
684
+ state.phaseScrollOffset = phaseStart;
685
+ // padding 到固定高度
686
+ while (leftLines.length < bodyH) leftLines.push("");
687
+ leftLines.length = bodyH;
688
+
689
+ // Right: agents in the currently selected phase only(固定高度 viewport + 滚动)
690
+ const selectedPhase = phases[state.phaseIdx] ?? phases[0];
691
+ rightLines.push(theme.fg("muted", selectedPhase
692
+ ? (selectedPhase.name
693
+ ? `${selectedPhase.name} · ${selectedPhase.nodes.length} agents · ${formatElapsed(run.meta.startedAt, now)}`
694
+ : `${selectedPhase.nodes.length} agents · ${formatElapsed(run.meta.startedAt, now)}`)
695
+ : "(no phase)"));
696
+ rightLines.push(dashes(theme, mainWidth));
697
+ if (selectedPhase) {
698
+ const { startIdx: agentStart, viewportH: agentViewportH } = computeViewport(selectedPhase.nodes.length, state.agentIdx, bodyH);
699
+ for (let i = agentStart; i < agentStart + agentViewportH && i < selectedPhase.nodes.length; i++) {
700
+ rightLines.push(formatAgentOneLiner(selectedPhase.nodes[i], theme));
701
+ }
702
+ state.agentScrollOffset = agentStart;
703
+ }
704
+ while (rightLines.length < bodyH) rightLines.push("");
705
+ rightLines.length = bodyH;
706
+
707
+ mergeBody(lines, leftLines, rightLines, theme);
708
+ }
709
+
710
+ // ── Level 1: Agent selection ──────────────────────────────────
711
+
712
+ function renderLevel1(
713
+ lines: string[],
714
+ _run: WorkflowRun,
715
+ phases: ReturnType<typeof buildPhaseGroups>,
716
+ state: ViewState,
717
+ theme: ThemeLike,
718
+ mainWidth: number,
719
+ now: number,
720
+ bodyH: number,
721
+ ): void {
722
+ const leftLines: string[] = [];
723
+ const rightLines: string[] = [];
724
+
725
+ // Left: sidebar title + phase list(固定高度 viewport + 滚动,只构建可见行)
726
+ leftLines.push(theme.fg("muted", "Phases"));
727
+ leftLines.push(dashes(theme, SIDEBAR_WIDTH));
728
+ const { startIdx: phaseStart, viewportH: phaseViewportH } = computeViewport(phases.length, state.phaseIdx, bodyH);
729
+ for (let i = phaseStart; i < phaseStart + phaseViewportH && i < phases.length; i++) {
730
+ leftLines.push(formatPhaseLine(phases[i], i, i === state.phaseIdx, theme, SIDEBAR_WIDTH));
731
+ }
732
+ state.phaseScrollOffset = phaseStart;
733
+ while (leftLines.length < bodyH) leftLines.push("");
734
+ leftLines.length = bodyH;
735
+
736
+ // Right: agent list(固定高度 viewport + 滚动)
737
+ const currentPhase = phases[state.phaseIdx];
738
+ const agents = currentPhase?.nodes ?? [];
739
+ if (currentPhase) {
740
+ rightLines.push(theme.fg("muted", currentPhase.name
741
+ ? `${currentPhase.name} · ${currentPhase.nodes.length} agents`
742
+ : `${currentPhase.nodes.length} agents`));
743
+ rightLines.push(dashes(theme, mainWidth));
744
+ } else {
745
+ rightLines.push(theme.fg("muted", "(no phase)"));
746
+ rightLines.push(dashes(theme, mainWidth));
747
+ }
748
+ const { startIdx: agentStart, viewportH: agentViewportH } = computeViewport(agents.length, state.agentIdx, bodyH);
749
+ for (let i = agentStart; i < agentStart + agentViewportH && i < agents.length; i++) {
750
+ const node = agents[i];
751
+ const pointer = i === state.agentIdx ? "❯ " : " ";
752
+ const dot = statusDotStr(node.status, theme);
753
+ // Live 路径优先:运行中从 node.live 读实时 token/tool 计数 + elapsed。
754
+ if (node.live) {
755
+ const live = projectLiveProgress(node.live);
756
+ const tokStr = live.totalTokens > 0 ? `${Math.round(live.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "";
757
+ const tcCount = getAllToolCalls(node.live).length;
758
+ const elapsed = formatElapsedSeconds(live.elapsedSeconds);
759
+ rightLines.push(`${pointer}${dot} ${node.agent} ${node.model} ${tokStr} · ${tcCount} tools · ${elapsed}`);
760
+ } else {
761
+ const elapsed = formatElapsed(
762
+ node.startedAt,
763
+ node.completedAt ? new Date(node.completedAt).getTime() : now,
764
+ );
765
+ const tok = node.result?.usage;
766
+ const tokStr = tok ? `${Math.round((tok.input + tok.output) / BUDGET_TOKENS_DIVISOR)}k tok` : "";
767
+ const tcCount = node.result?.toolCalls?.length ?? 0;
768
+ rightLines.push(`${pointer}${dot} ${node.agent} ${node.model} ${tokStr} · ${tcCount} tools · ${elapsed}`);
769
+ }
770
+ }
771
+ state.agentScrollOffset = agentStart;
772
+ while (rightLines.length < bodyH) rightLines.push("");
773
+ rightLines.length = bodyH;
774
+
775
+ mergeBody(lines, leftLines, rightLines, theme);
776
+ }
777
+
778
+ // ── Level 2: Execution detail ─────────────────────────────────
779
+ // 详情内容构建 + 滚动按键已抽到 detail-content.ts(纯函数,可单测,且控制本文件行数)。
780
+
781
+ function renderLevel2(
782
+ lines: string[],
783
+ run: WorkflowRun,
784
+ agents: ExecutionTraceNode[],
785
+ state: ViewState,
786
+ theme: ThemeLike,
787
+ mainWidth: number,
788
+ now: number,
789
+ viewH: number,
790
+ ): void {
791
+ const leftLines: string[] = [];
792
+ const rightLines: string[] = [];
793
+
794
+ // Left: agents title + agent names
795
+ leftLines.push(theme.fg("muted", "Agents"));
796
+ leftLines.push(dashes(theme, SIDEBAR_WIDTH));
797
+ const AGENT_NAME_BUDGET = 4; // pointer(2) + spacing(2)
798
+ for (let i = 0; i < agents.length; i++) {
799
+ const a = agents[i];
800
+ const pointer = i === state.agentIdx ? "❯ " : " ";
801
+ const maxNameWidth = SIDEBAR_WIDTH - AGENT_NAME_BUDGET;
802
+ const agentName = visibleLen(a.agent) > maxNameWidth
803
+ ? a.agent.slice(0, maxNameWidth - 1) + ELLIPSIS
804
+ : a.agent;
805
+ leftLines.push(`${pointer}${agentName}`);
806
+ }
807
+
808
+ // Right: full detail(viewport 截断 + 滚动,对齐 subagents renderRightDetail)
809
+ const node = agents[state.agentIdx];
810
+ if (node) {
811
+ const content = buildDetailContent(node, state, run, theme, mainWidth, now);
812
+ const maxOff = Math.max(0, content.length - viewH);
813
+ // running 且 followTail → 钉底部(最新输出始终可见,用户 PgUp 后停止跟随)
814
+ if (node.status === "running" && state.followTail) {
815
+ state.detailScrollOffset = maxOff;
816
+ }
817
+ // clamp 收敛(切 agent / End 越界后下次 render 归位)
818
+ if (state.detailScrollOffset > maxOff) state.detailScrollOffset = maxOff;
819
+ const start = state.detailScrollOffset;
820
+ const visible = content.slice(start, start + viewH);
821
+ while (visible.length < viewH) visible.push(""); // pad 填满视口
822
+ rightLines.push(...visible);
823
+
824
+ // 位置指示(仅内容超一屏时,对齐 subagents detailScrollInfo)
825
+ if (content.length > viewH) {
826
+ const end = Math.min(start + viewH, content.length);
827
+ // 覆盖第一行标题,拼上 (start+1-end/total)
828
+ const titleBase = "Detail";
829
+ const indicator = ` (${start + 1}-${end}/${content.length})`;
830
+ rightLines[0] = theme.fg("muted", titleBase + indicator);
831
+ }
832
+ }
833
+
834
+ // body 固定高度 = viewH(左右都 pad/截到 viewH,避免内容撑高溢出)
835
+ const bodyH = viewH;
836
+ const leftPadded: string[] = [];
837
+ for (let i = 0; i < bodyH; i++) leftPadded.push(leftLines[i] ?? "");
838
+ const rightPadded: string[] = [];
839
+ for (let i = 0; i < bodyH; i++) rightPadded.push(rightLines[i] ?? "");
840
+ mergeBody(lines, leftPadded, rightPadded, theme);
841
+ }
842
+
843
+ // ── Trace export(S 键,对齐 main saveTraceToFile)─────────────
844
+
845
+ /** trace 导出文件每节点 outcome 截断长度。 */
846
+ const TRACE_OUTCOME_SLICE = 2000;
847
+ /** trace 导出文件 activity 行宽。 */
848
+ const TRACE_ACTIVITY_WIDTH = 80;
849
+
850
+ /**
851
+ * 导出完整 workflow trace 到 Markdown 文件。
852
+ * 路径:~/.pi/agent/workflow-traces/{runId}.md
853
+ * 对齐 main 的 saveTraceToFile(WorkflowsView.ts:365-396)。
854
+ */
855
+ function saveTraceToFile(run: WorkflowRun, ctx: ExtensionContext): void {
856
+ const dir = pathJoin(homedir(), ".pi", "agent", "workflow-traces");
857
+ const filePath = pathJoin(dir, `${run.runId}.md`);
858
+ const lines: string[] = [];
859
+ lines.push(`# Workflow Trace: ${run.spec.scriptName} (${run.runId})`, "");
860
+ lines.push(`Status: ${run.state.status} | Started: ${run.meta.startedAt ?? "-"} | Duration: ${formatElapsed(run.meta.startedAt)}`);
861
+ const budget = run.state.budget;
862
+ lines.push(`Budget: ${budget.usedTokens}/${budget.maxTokens ?? "unlimited"} tokens, $${budget.usedCost.toFixed(BUDGET_COST_DECIMALS)}`, "");
863
+ const phases = buildPhaseGroups([...run.state.trace.toArray()]);
864
+ for (const pg of phases) {
865
+ lines.push(`## Phase: ${pg.name || "(unnamed)"}`, "");
866
+ for (const node of pg.nodes) {
867
+ lines.push(`### [#${node.stepIndex}] ${node.agent} — ${node.status}`);
868
+ lines.push(`- Model: ${node.model}`);
869
+ lines.push(`- Duration: ${formatElapsed(node.startedAt, node.completedAt ? new Date(node.completedAt).getTime() : Date.now())}`, "");
870
+ lines.push("**Prompt:**", node.task, "");
871
+ const toolCalls = node.result?.toolCalls ?? [];
872
+ if (toolCalls.length > 0) {
873
+ lines.push("**Activity:**");
874
+ for (const tc of toolCalls) lines.push(`- ${formatActivityLine(tc, TRACE_ACTIVITY_WIDTH)}`);
875
+ lines.push("");
876
+ }
877
+ lines.push("**Outcome:**");
878
+ if (node.status === "running") lines.push("Still running...");
879
+ else if (node.result?.error) lines.push(node.result.error);
880
+ else if (node.result?.content) lines.push(node.result.content.slice(0, TRACE_OUTCOME_SLICE));
881
+ lines.push("");
882
+ }
883
+ }
884
+ fsPromises.mkdir(dir, { recursive: true })
885
+ .then(() => fsPromises.writeFile(filePath, lines.join("\n"), "utf8"))
886
+ .then(() => ctx.ui.notify(`Trace saved: ${filePath}`, "info"))
887
+ .catch((err: Error) => ctx.ui.notify(`Save failed: ${err.message}`, "error"));
888
+ }
889
+
890
+ // ── Save overlay(缺陷 #3 恢复,从 main 移植简化版)────────────
891
+
892
+ /**
893
+ * save overlay——名称输入框 + 状态消息。
894
+ * refactor 的 saveWorkflow 仅支持 project scope(workflow-files.ts 统一为 rename),
895
+ * 故去掉 main 的 scope 切换(Tab),只保留名称输入。
896
+ */
897
+ function renderSaveOverlay(
898
+ state: ViewState,
899
+ theme: ThemeLike,
900
+ width: number,
901
+ ): string[] {
902
+ const contentWidth = width - BOX_BORDER_CHARS;
903
+ const lines: string[] = [];
904
+
905
+ lines.push(plainBorder(theme, "╭", "╮", contentWidth));
906
+
907
+ // Title
908
+ lines.push(walled(theme, theme.bold(" Save dynamic workflow"), contentWidth));
909
+
910
+ // Destination preview
911
+ const destName = state.saveInputValue || "(name)";
912
+ const destLine = `.pi/workflows/${destName}.js`;
913
+ lines.push(walled(theme, theme.fg("dim", destLine), contentWidth));
914
+
915
+ // Empty line
916
+ lines.push(walled(theme, "", contentWidth));
917
+
918
+ // Label
919
+ lines.push(walled(theme, "Save as:", contentWidth));
920
+
921
+ // Input line with cursor block
922
+ const inputLine = ` > ${state.saveInputValue}\u2588`;
923
+ lines.push(walled(theme, inputLine, contentWidth));
924
+
925
+ // Empty line
926
+ lines.push(walled(theme, "", contentWidth));
927
+
928
+ // Inline message (error or success)
929
+ if (state.saveMessage) {
930
+ const msgStyle = state.saveMsgOk ? "success" : "error";
931
+ const msgLine = ` ${state.saveMessage}`;
932
+ lines.push(walled(theme, theme.fg(msgStyle, msgLine), contentWidth));
933
+ } else {
934
+ lines.push(walled(theme, "", contentWidth));
935
+ }
936
+
937
+ // Hint
938
+ const hint = "Enter to save · Esc to cancel";
939
+ lines.push(walled(theme, theme.fg("muted", hint), contentWidth));
940
+
941
+ lines.push(plainBorder(theme, "╰", "╯", contentWidth));
942
+
943
+ return lines;
944
+ }