@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,643 @@
1
+ // src/tui/list-component.ts
2
+ //
3
+ // /subagents list 全屏带框左右分屏组件实现。
4
+ // 从 list-view.ts 抽出(文件行数控制):list-view.ts 保留 factory / key 处理,
5
+ // 组件渲染逻辑(边框、分屏布局、详情翻屏)归此文件。
6
+ //
7
+ // 依赖关系:组件只读 service.collectRecords + applyFilter,状态由 list-view 注入(ViewState)。
8
+ // 输入分发委托 keyHandler(list-view factory 注入 processKey),组件本身不处理按键语义。
9
+ // 组件 import list-shared(共享契约),不 import list-view → list-view → 组件单向依赖,无循环。
10
+
11
+ import type { Component } from "@earendil-works/pi-tui";
12
+ import { visibleWidth } from "@earendil-works/pi-tui";
13
+
14
+ import { computeElapsedSeconds } from "../execution/execution-record.ts";
15
+ import type { SubagentService } from "../execution/subagent-service.ts";
16
+ import type { SubagentRecord } from "../execution/types.ts";
17
+ import {
18
+ firstLine,
19
+ formatDisplayItem,
20
+ formatElapsedSeconds,
21
+ formatEventLine,
22
+ formatTokens,
23
+ padToVisible,
24
+ sanitizeLabel,
25
+ segFillColored,
26
+ shortId,
27
+ spinnerGlyph,
28
+ statusGlyph,
29
+ type ThemeLike,
30
+ truncLine,
31
+ wrapText,
32
+ } from "./format.ts";
33
+ import {
34
+ applyFilter,
35
+ type DetailKeyContext,
36
+ type KeyHandler,
37
+ LIST_LIMIT,
38
+ type NotifyFn,
39
+ type TuiLike,
40
+ type ViewState,
41
+ } from "./list-shared.ts";
42
+
43
+ // ── 组件专用布局常量(factory/key 层不使用) ──
44
+
45
+ /** 左列占比。 */
46
+ const LEFT_COL_RATIO = 0.32;
47
+ /** 列最小宽度。 */
48
+ const COL_MIN_WIDTH = 20;
49
+ /** 列内最小内容宽度(兜底防负)。 */
50
+ const COL_INNER_MIN = 4;
51
+ /** 列内缩进("→ " 或 " " 前缀宽度)。 */
52
+ const COL_INDENT = 2;
53
+ /** 右列预览的最近 eventLog 条数。 */
54
+ const PREVIEW_RECENT_LINES = 3;
55
+
56
+ // ── 边框常量 ──
57
+ /** 左右边框字符宽度(│ x 2)。 */
58
+ const BORDER_WIDTH = 2;
59
+ /** 分屏模式下,框内**不滚动**的固定行数(顶框 1 + filter 1 + 分区线 1 + 底分区线 1 + footer 1 + 底框 1)。 */
60
+ const SPLIT_FIXED_LINES = 6;
61
+ /** 终端最小行数(低于此回退紧凑空列表框)。 */
62
+ const MIN_TERM_ROWS = 8;
63
+ /** terminal.rows 读不到时的兜底行数(防 duck-type 失败)。 */
64
+ const TERM_ROWS_FALLBACK = 24;
65
+ /** 自画视觉边距:框外左右各 1 列空白(盖住底下对话流)。 */
66
+ const PAD_COLS = 2;
67
+ /** 自画视觉边距:框外顶底各 1 行空白。 */
68
+ const PAD_ROWS = 2;
69
+ /** 内框最小宽(兜底防极窄终端)。 */
70
+ const MIN_INNER_WIDTH = 4;
71
+ /** 内框最小高(兜底防极矮终端)。 */
72
+ const MIN_INNER_ROWS = 4;
73
+ /** 详情内容总行数探测宽度(够大避免截断折行影响行数统计)。 */
74
+ const DETAIL_LEN_PROBE_WIDTH = 9999;
75
+ /** 垂直居中除数(floor(剩余/2))。 */
76
+ const VERT_CENTER_DIVISOR = 2;
77
+ /** spinner 帧切换粒度(与 Date.now() 配合选帧)。 */
78
+ const SPINNER_FRAME_MS = 250;
79
+ /** 顶框嵌入标题(分屏模式)。 */
80
+ const TITLE_SPLIT = "Subagents";
81
+ /** 分屏分区线左/右嵌入标题。 */
82
+ const TITLE_LEFT = "Records";
83
+ const TITLE_RIGHT = "Detail";
84
+
85
+ /**
86
+ * 全屏带框左右分屏 list 组件。
87
+ *
88
+ * 不缓存行(records 每次 render 都从 service.collectRecords 拉最新——保证 store 变化后刷新)。
89
+ * 缓存的是「上次 render 的 width×rows」(用于 invalidate 后强制重建)。
90
+ */
91
+ export class SubagentsListComponent implements Component {
92
+ private cachedKey: string | undefined;
93
+ private cachedLines: string[] | undefined;
94
+ private closeFn: () => void = () => {};
95
+ /** 动画 timer 句柄(dispose 兜底清理)。 */
96
+ private animTimer: ReturnType<typeof setInterval> | undefined;
97
+
98
+ constructor(
99
+ private readonly service: SubagentService,
100
+ private readonly theme: ThemeLike,
101
+ private readonly tui: TuiLike,
102
+ private readonly state: ViewState,
103
+ private readonly unsubscribe: () => void,
104
+ private readonly notify: NotifyFn,
105
+ /** 按键处理(list-view 的 processKey,依赖注入避免组件 import list-view)。 */
106
+ private readonly keyHandler: KeyHandler,
107
+ ) {}
108
+
109
+ setCloseFn(fn: () => void): void {
110
+ this.closeFn = fn;
111
+ }
112
+
113
+ /** 注入动画 timer 句柄(dispose 兜底清理用)。 */
114
+ setAnimTimer(timer: ReturnType<typeof setInterval>): void {
115
+ this.animTimer = timer;
116
+ }
117
+
118
+ /** 是否有 running record(动画 timer 据此决定是否刷新)。 */
119
+ hasRunning(): boolean {
120
+ return this.service.collectRecords(LIST_LIMIT).some((r) => r.status === "running");
121
+ }
122
+
123
+ invalidate(): void {
124
+ this.cachedKey = undefined;
125
+ this.cachedLines = undefined;
126
+ }
127
+
128
+ render(width: number): string[] {
129
+ const rows = this.termRows();
130
+ const key = `${width}x${rows}`;
131
+ if (key === this.cachedKey && this.cachedLines) return this.cachedLines;
132
+ const lines = this.buildLines(width, rows);
133
+ this.cachedKey = key;
134
+ this.cachedLines = lines;
135
+ return lines;
136
+ }
137
+
138
+ handleInput(data: string): void {
139
+ if (this.state.disposed) return;
140
+
141
+ const records = applyFilter(this.service.collectRecords(LIST_LIMIT), this.state.filterText);
142
+ const selected = records[this.state.selectedIdx] ?? null;
143
+ // 详情翻屏上下文:视口高 = 右侧 body 高(内框高 - SPLIT_FIXED_LINES),
144
+ // contentLines = 详情内容总行数(含元数据/段头/eventLog/result/error,单一数据源)。
145
+ // 与 renderRightDetail 的 viewH + max 计算保持一致。
146
+ const innerRows = Math.max(MIN_INNER_ROWS, this.termRows() - PAD_ROWS);
147
+ const bodyH = Math.max(1, innerRows - SPLIT_FIXED_LINES);
148
+ const detailCtx: DetailKeyContext = {
149
+ viewportHeight: bodyH,
150
+ contentLines: selected ? this.detailContentLength(selected) : 0,
151
+ };
152
+
153
+ const result = this.keyHandler(data, records, this.state, selected, this.service, detailCtx, this.notify);
154
+
155
+ if (result.exit) {
156
+ this.closeFn();
157
+ return;
158
+ }
159
+ if (result.changed) {
160
+ this.invalidate();
161
+ this.tui.requestRender();
162
+ }
163
+ }
164
+
165
+ /** 安全读 terminal.rows(兜底防 duck-type 失败)。 */
166
+ private termRows(): number {
167
+ const rows = this.tui.terminal?.rows;
168
+ return typeof rows === "number" && rows > 0 ? rows : TERM_ROWS_FALLBACK;
169
+ }
170
+
171
+ // ── 内部:渲染 ──────────────────────────────────────────
172
+
173
+ /**
174
+ * 构建行数组(全屏覆盖 + 自画视觉边距)。
175
+ *
176
+ * width = render 收到的全屏宽(margin:0 → termCols,overlay 覆盖整个终端)
177
+ * rows = terminal.rows(满屏高)
178
+ *
179
+ * overlay 不用 Pi 的 margin(那是物理留白会透出底下内容),改 margin:0 全屏覆盖,
180
+ * 自己在框外加 1 行/1 列空白(盖住底下的对话流):
181
+ * - 每行:` ` + 框行 + ` `(左右各 1 空格视觉边距)
182
+ * - 顶/底:各 1 行全宽空白
183
+ * - 内框宽 = width - 2(左右边距),内框高 = rows - 2(顶底边距)
184
+ *
185
+ * 分三个分支(基于内框尺寸):
186
+ * 1. 终端太矮(< MIN_TERM_ROWS)→ 紧凑提示,不画框
187
+ * 2. 空列表 → 紧凑小框(不填满全屏)
188
+ * 3. 有 records → 分屏满屏框(detailMode 控制右侧预览 vs 完整翻屏,不再切全屏页)
189
+ */
190
+ private buildLines(width: number, rows: number): string[] {
191
+ // 内框尺寸(减去左右 1 列 + 顶底 1 行的视觉边距)
192
+ const innerWidth = Math.max(MIN_INNER_WIDTH, width - PAD_COLS);
193
+ const innerRows = Math.max(MIN_INNER_ROWS, rows - PAD_ROWS);
194
+
195
+ const allRecords = this.service.collectRecords(LIST_LIMIT);
196
+ const records = applyFilter(allRecords, this.state.filterText);
197
+
198
+ // 先在内框尺寸下生成框行
199
+ let innerLines: string[];
200
+ if (rows < MIN_TERM_ROWS) {
201
+ innerLines = this.renderTooSmall(innerWidth);
202
+ } else if (allRecords.length === 0) {
203
+ // 真正的空列表(无任何 subagent)→ 紧凑小框
204
+ innerLines = this.renderEmptyBox(innerWidth);
205
+ } else {
206
+ // 有 records(即使 filter 无匹配,也保留分屏布局——只清空左右内容区)
207
+ this.state.selectedIdx = Math.min(this.state.selectedIdx, Math.max(0, records.length - 1));
208
+ innerLines = this.renderSplitBox(records, innerWidth, innerRows);
209
+ }
210
+
211
+ return this.applyPadding(innerLines, width, rows);
212
+ }
213
+
214
+ /**
215
+ * 给内框行套视觉边距并填满全屏:顶/底各加空白行直到满屏高,每行加左右 1 空格。
216
+ * 这些空白是 overlay 自己画的(盖住底下对话流),区别于 Pi 的物理 margin(透出底内容)。
217
+ * 紧凑框(空列表/太矮)也会被空白填满全屏——保证整个终端被 overlay 覆盖。
218
+ */
219
+ private applyPadding(innerLines: string[], width: number, rows: number): string[] {
220
+ const blank = " ".repeat(width);
221
+ // 左右各加 1 空格的边距行(内框行 visibleWidth 已 = width - 2)
222
+ const padLine = (line: string) => ` ${line} `;
223
+ const result: string[] = [];
224
+ // 顶部空白填满(紧凑框时把框垂直居中)
225
+ const topPad = Math.max(1, Math.floor((rows - innerLines.length) / VERT_CENTER_DIVISOR));
226
+ for (let i = 0; i < topPad; i++) result.push(blank);
227
+ for (const line of innerLines) result.push(padLine(line));
228
+ // 底部空白填满到 rows
229
+ while (result.length < rows) result.push(blank);
230
+ return result;
231
+ }
232
+
233
+ // ── 边框着色 helper(统一 borderMuted,避 ANSI 嵌套失色)──
234
+
235
+ /** 着色框线字符(borderMuted)。所有 ╭╮╰╯├┤┬┴─│ 统一走这里。 */
236
+ private b(s: string): string {
237
+ return this.theme.fg("borderMuted", s);
238
+ }
239
+ /** 着色单字符填充用的 `─`(供 segFillColored 的 fillStyled)。 */
240
+ private dash(): string {
241
+ return this.theme.fg("borderMuted", "─");
242
+ }
243
+ /** 满宽 `─` 填充串(borderMuted)。n 次单字符着色,ANSI 自然延续。 */
244
+ private dashes(n: number): string {
245
+ return this.dash().repeat(Math.max(0, n));
246
+ }
247
+ /** 顶/底框行:`╭` + 着色标题填充 + `╮`(或 ╰╯)。每段独立着色,无嵌套。 */
248
+ private titleBorder(left: string, titleStyled: string, right: string, contentWidth: number): string {
249
+ return this.b(left) + segFillColored(titleStyled, this.dash(), contentWidth) + this.b(right);
250
+ }
251
+ /** 纯线顶/底框(无标题):`╭` + `─`×W + `╮`。 */
252
+ private plainBorder(left: string, right: string, contentWidth: number): string {
253
+ return this.b(left) + this.dashes(contentWidth) + this.b(right);
254
+ }
255
+ /** 内容行墙:`│` + 内容(pad 到 contentWidth) + `│`,墙字符 borderMuted。 */
256
+ private walled(content: string, contentWidth: number): string {
257
+ return `${this.b("│")}${padToVisible(content, contentWidth)}${this.b("│")}`;
258
+ }
259
+
260
+ // ── 分支 1:终端太小 ──────────────────────────────────
261
+
262
+ private renderTooSmall(width: number): string[] {
263
+ const t = this.theme;
264
+ const contentWidth = Math.max(1, width - BORDER_WIDTH);
265
+ const msg = t.fg("warning", `Terminal too small (need >=${MIN_TERM_ROWS} rows)`);
266
+ return [
267
+ this.plainBorder("╭", "╮", contentWidth),
268
+ this.walled(padToVisible(msg, contentWidth), contentWidth),
269
+ this.plainBorder("╰", "╯", contentWidth),
270
+ ];
271
+ }
272
+
273
+ // ── 分支 2:空列表紧凑框 ──────────────────────────────
274
+
275
+ private renderEmptyBox(width: number): string[] {
276
+ const t = this.theme;
277
+ const contentWidth = Math.max(1, width - BORDER_WIDTH);
278
+ const title = t.fg("accent", t.bold(` ${TITLE_SPLIT} `));
279
+ return [
280
+ this.titleBorder("╭", title, "╮", contentWidth),
281
+ this.walled("", contentWidth),
282
+ this.walled(truncLine(t.fg("dim", "(no subagent records)"), contentWidth), contentWidth),
283
+ this.walled("", contentWidth),
284
+ this.walled(truncLine(t.fg("dim", "Esc to exit"), contentWidth), contentWidth),
285
+ this.plainBorder("╰", "╯", contentWidth),
286
+ ];
287
+ }
288
+
289
+ // ── 分支 3:分屏满屏框(detailMode 控制右侧预览 vs 完整翻屏)──
290
+
291
+ private renderSplitBox(records: SubagentRecord[], width: number, rows: number): string[] {
292
+ const t = this.theme;
293
+ const contentWidth = Math.max(1, width - BORDER_WIDTH);
294
+ // 左右列宽:左按比例,右占余下(减去分隔符 1 列)
295
+ const leftWidth = Math.max(COL_MIN_WIDTH, Math.floor(contentWidth * LEFT_COL_RATIO));
296
+ const rightWidth = Math.max(COL_MIN_WIDTH, contentWidth - leftWidth - 1);
297
+ const sep = this.b("│");
298
+
299
+ // 满屏可用 body 高 = 内框高 - 固定行(顶框/filter/分区线/底分区线/footer/底框 = 6)
300
+ // rows 参数已是内框高(顶底空白边距已在 buildLines 扣除)。
301
+ const bodyH = Math.max(1, rows - SPLIT_FIXED_LINES);
302
+
303
+ const selected = records[this.state.selectedIdx] ?? null;
304
+ const inDetail = this.state.detailMode; // 阶段 2:右侧滚动焦点
305
+ // 预构建详情内容(inDetail 时):分区线标题(detailScrollInfo 算长度)与右列(renderRightDetail 渲染)
306
+ // 共用同一份,避免每帧双倍构建(animTimer 250ms 触发,长 eventLog 下有感)。
307
+ const detailContent = inDetail && selected ? this.buildDetailContent(selected, rightWidth) : null;
308
+
309
+ const lines: string[] = [];
310
+
311
+ // 顶框(嵌入标题,分段着色)
312
+ lines.push(this.titleBorder("╭", t.fg("accent", t.bold(` ${TITLE_SPLIT} `)), "╮", contentWidth));
313
+
314
+ // filter 行(阶段 2 时隐藏 filter 提示,显示锚定提示)
315
+ const filterLine = inDetail
316
+ ? t.fg("dim", `Pinned: ${selected?.agent ?? ""} · Esc to return to list`)
317
+ : (this.state.filterText
318
+ ? `${t.fg("dim", "filter: ")}${t.bold(this.state.filterText)}${t.fg("accent", "_")}`
319
+ : `${t.fg("dim", "filter: ")}${t.fg("accent", "_")}`);
320
+ lines.push(this.walled(padToVisible(truncLine(filterLine, contentWidth), contentWidth), contentWidth));
321
+
322
+ // 分区线(嵌入左/右标题,分段着色)
323
+ const leftTitleStyled = t.fg("accent", t.bold(` ${TITLE_LEFT} `));
324
+ const rightTitleStyled = inDetail
325
+ ? t.fg("accent", t.bold(` ${TITLE_RIGHT}${this.detailScrollInfo(selected, bodyH, detailContent?.length)} `))
326
+ : t.fg("accent", t.bold(` ${TITLE_RIGHT} `));
327
+ lines.push(
328
+ this.b("├") + segFillColored(leftTitleStyled, this.dash(), leftWidth)
329
+ + this.b("┬") + segFillColored(rightTitleStyled, this.dash(), rightWidth) + this.b("┤"),
330
+ );
331
+
332
+ // body:左列 record 列表 + 右列(预览 or 完整翻屏)
333
+ let leftLines: string[];
334
+ let rightLines: string[];
335
+ if (records.length === 0) {
336
+ // filter 无匹配:保留分屏布局,左右都显示提示
337
+ leftLines = [t.fg("dim", `(no match for "${this.state.filterText}")`)];
338
+ rightLines = [t.fg("dim", "(no record selected)")];
339
+ } else {
340
+ // 左列视口窗口:选中行尽量居中,到列表顶/底贴边。
341
+ // 保证 leftLines.length <= bodyH → bodyRows = bodyH 恒定,帧行不溢出终端(无残影)。
342
+ const maxLeftStart = Math.max(0, records.length - bodyH);
343
+ const leftStart = Math.max(0, Math.min(
344
+ Math.floor(this.state.selectedIdx - bodyH / VERT_CENTER_DIVISOR),
345
+ maxLeftStart,
346
+ ));
347
+ leftLines = this.renderLeftColumn(records, leftWidth, leftStart, bodyH);
348
+ rightLines = inDetail
349
+ ? this.renderRightDetail(selected, rightWidth, bodyH, detailContent)
350
+ : this.renderRightPreview(selected, rightWidth, bodyH);
351
+ }
352
+ const bodyRows = Math.max(leftLines.length, rightLines.length, bodyH);
353
+ for (let i = 0; i < bodyRows; i++) {
354
+ const l = leftLines[i] ?? "";
355
+ const r = rightLines[i] ?? "";
356
+ const row = `${padToVisible(truncLine(l, leftWidth), leftWidth)}${sep}${padToVisible(truncLine(r, rightWidth), rightWidth)}`;
357
+ lines.push(this.walled(padToVisible(row, contentWidth), contentWidth));
358
+ }
359
+
360
+ // 底分区线
361
+ lines.push(this.b("├") + this.dashes(leftWidth) + this.b("┴") + this.dashes(rightWidth) + this.b("┤"));
362
+
363
+ // footer(双文案)
364
+ const footer = inDetail
365
+ ? t.fg("dim", "Esc back to list · Up/Dn/PgUp/PgDn/Home/End scroll detail" + this.cancelHint(selected))
366
+ : t.fg("dim", "Up/Dn navigate · Enter detail · type to filter · Esc exit");
367
+ lines.push(this.walled(padToVisible(truncLine(footer, contentWidth), contentWidth), contentWidth));
368
+
369
+ // 底框
370
+ lines.push(this.plainBorder("╰", "╯", contentWidth));
371
+
372
+ return lines;
373
+ }
374
+
375
+ /** 详情模式滚动位置指示(嵌入分区线标题),如 "Detail (5-12/30)"。无内容则空。
376
+ * contentLen 由调用方(renderSplitBox)从预构建的 detailContent 传入,避免重复构建。 */
377
+ private detailScrollInfo(record: SubagentRecord | null, viewH: number, contentLen?: number): string {
378
+ if (!record) return "";
379
+ const len = contentLen ?? this.detailContentLength(record);
380
+ if (len <= viewH) return ""; // 内容一屏装下,不显示
381
+ const max = Math.max(0, len - viewH);
382
+ const start = Math.max(0, Math.min(this.state.scrollOffset, max));
383
+ const end = Math.min(start + viewH, len);
384
+ return ` (${start + 1}-${end}/${len})`;
385
+ }
386
+
387
+ /** footer 的取消提示(仅 running 时显示)。 */
388
+ private cancelHint(record: SubagentRecord | null): string {
389
+ if (!record || record.status !== "running") return "";
390
+ return record.mode === "background" ? " · x stop" : " · x stop (hint)";
391
+ }
392
+
393
+ /**
394
+ * 左列:record 列表(带视口窗口)。阶段 2(detailMode)时非锚定行 dim,锚定行用 ▶。
395
+ *
396
+ * 视口窗口 [startIdx, startIdx+count):record 数超过 bodyH 时只渲染选中行附近的窗口,
397
+ * 保证 leftLines.length <= bodyH → bodyRows = bodyH 恒定 → 帧行不溢出终端。
398
+ * 溢出会导致 overlay 无法清屏,残留旧帧行(递归场景 record 多,必然触发)。
399
+ * 窗口由 renderSplitBox 按 selectedIdx 居中算定,此处只做切片渲染。
400
+ */
401
+ private renderLeftColumn(records: SubagentRecord[], width: number, startIdx: number, count: number): string[] {
402
+ const t = this.theme;
403
+ const innerWidth = Math.max(COL_INNER_MIN, width - COL_INDENT);
404
+ const inDetail = this.state.detailMode;
405
+ // spinner 当前帧(Date.now() 驱动;animTimer 定期 invalidate → render 重选帧)
406
+ const spinFrame = spinnerGlyph(Math.floor(Date.now() / SPINNER_FRAME_MS));
407
+ const endIdx = Math.min(records.length, startIdx + count);
408
+ const lines: string[] = [];
409
+ for (let i = startIdx; i < endIdx; i++) {
410
+ const r = records[i];
411
+ const selected = i === this.state.selectedIdx;
412
+ const glyph = statusGlyph(r.status);
413
+ const icon = glyph.icon ?? spinFrame;
414
+ const iconStr = t.fg(glyph.color, icon);
415
+ const modeTag = "bg";
416
+ const dur = formatElapsedSeconds(elapsedSec(r));
417
+ // 短编号(dim)置于行首——列表一眼看到「第几个」, 不必进详情.
418
+ const sid = t.fg("dim", shortId(r.id));
419
+ // 方案 D:递归深度标记。顶层(depth=0, 主 session 直接创建)不显示;
420
+ // depth≥1 显示 [L2]/[L3]...——平铺列表一眼区分哪些是嵌套产生的,不干扰 fan-out 场景。
421
+ const depthTag = r.depth > 0 ? ` ${t.fg("dim", `[L${r.depth + 1}]`)}` : "";
422
+ const label = `${iconStr} ${sid}${depthTag} ${r.agent} ${t.fg("dim", modeTag)} ${t.fg("dim", dur)}`;
423
+ // 阶段 2:锚定行 accent + ▶;其余行 dim。阶段 1:选中 accent + →,其余正常。
424
+ const content = inDetail
425
+ ? (selected ? t.fg("accent", label) : t.fg("dim", label))
426
+ : (selected ? t.fg("accent", label) : label);
427
+ const prefix = selected ? (inDetail ? "▶ " : "→ ") : " ";
428
+ lines.push(`${prefix}${truncLine(content, innerWidth)}`);
429
+ }
430
+ return lines;
431
+ }
432
+
433
+ /** 右列:选中 record 的预览(阶段 1)。bodyH 截断防小终端溢出(见 renderSplitBox 不变量)。 */
434
+ private renderRightPreview(record: SubagentRecord | null, width: number, bodyH: number): string[] {
435
+ const t = this.theme;
436
+ if (!record) return [t.fg("dim", "(no record selected)")];
437
+
438
+ const lines: string[] = [];
439
+ // task 置顶——这是「subagent 在干什么」的唯一线索(streaming 时尤甚)。
440
+ // 预览阶段不进详情也必须可见,否则用户浏览列表时无法判断每条记录的任务。
441
+ if (record.task) {
442
+ // task 取首行——prompt 常含换行(多行指令),直接渲染会因 \n 意外换行,
443
+ // 破坏右列行对齐(每行变多行,后续内容全部错位)。完整多行 task 在 detail 模式可滚屏查看。
444
+ const taskLine = firstLine(record.task);
445
+ if (taskLine) {
446
+ lines.push(truncLine(t.fg("accent", `task: ${taskLine}`), width));
447
+ lines.push("");
448
+ }
449
+ }
450
+ lines.push(truncLine(`${t.bold(record.agent)} ${t.fg("dim", `· ${record.model}`)}`, width));
451
+ lines.push(truncLine(
452
+ t.fg("dim", `${record.status} · ${record.turns} turns · ${formatTokens(record.totalTokens)} · ${formatElapsedSeconds(elapsedSec(record))}`),
453
+ width,
454
+ ));
455
+ // 完整 id(含 background 时间戳): cancel/read session file 需精确引用. 左列只显示短编号.
456
+ lines.push(truncLine(t.fg("dim", `id: ${record.id}`), width));
457
+ // 层级:父 subagent(顶层显示 root)——不需要外部数据,record 自带 parentRecordId。
458
+ lines.push(truncLine(
459
+ t.fg("dim", `parent: ${record.parentRecordId ? shortId(record.parentRecordId) : "(root)"}`),
460
+ width,
461
+ ));
462
+ lines.push("");
463
+
464
+ // displayItems 从 turns[] 派生(含完整 text + toolCall),比 eventLog 信息密度高——
465
+ // eventLog 的 turn_end 丢弃 text 正文,预览看不到 subagent 输出。改用 displayItems
466
+ // 让流式/终态都能看到 text。running 时实时派生,终态从重建 turns[] 派生。
467
+ const recent = record.displayItems.slice(-PREVIEW_RECENT_LINES);
468
+ if (recent.length === 0 && record.eventLog.length > 0) {
469
+ // displayItems 为空但 eventLog 有(旧数据兜底):回退 eventLog。
470
+ for (const entry of record.eventLog.slice(-PREVIEW_RECENT_LINES)) {
471
+ lines.push(truncLine(formatEventLine(entry, t), width));
472
+ }
473
+ } else if (recent.length === 0) {
474
+ lines.push(truncLine(t.fg("dim", "(no output)"), width));
475
+ } else {
476
+ for (const item of recent) {
477
+ lines.push(truncLine(formatDisplayItem(item, t), width));
478
+ }
479
+ }
480
+
481
+ lines.push("");
482
+ lines.push(truncLine(t.fg("dim", "Enter for full detail"), width));
483
+ // 截断到 bodyH:小终端(tmux 分屏 bodyH 可能 2-6 行)下预览固定 ~10 行会溢出框,
484
+ // 导致 bodyRows = max(left,right,bodyH) > bodyH → 帧行把底分区线/footer/底框推出终端(残影)。
485
+ // 左列已有视口窗口保证 <= bodyH,右列预览在此对齐。优先保留头部身份信息。
486
+ return lines.slice(0, bodyH);
487
+ }
488
+
489
+ /**
490
+ * 右列:完整详情(阶段 2,detailMode)。完整 eventLog + result/error + sessionFile,
491
+ * scrollOffset 翻屏。顶部对齐(task 置顶可见)——Enter 进阶段 2 时 scrollOffset=0。
492
+ *
493
+ * 内容行生成与 detailContentLength 共用 buildDetailContent(单一数据源)。
494
+ */
495
+ private renderRightDetail(record: SubagentRecord | null, width: number, viewH: number, content?: string[] | null): string[] {
496
+ const t = this.theme;
497
+ if (!record) return [t.fg("dim", "(no record selected)")];
498
+
499
+ const lines = content ?? this.buildDetailContent(record, width);
500
+ // 翻屏(顶部对齐:scrollOffset ∈ [0, max])
501
+ const max = Math.max(0, lines.length - viewH);
502
+ if (this.state.scrollOffset > max) this.state.scrollOffset = max;
503
+ const start = Math.max(0, Math.min(this.state.scrollOffset, max));
504
+ this.state.scrollOffset = start; // 回写收敛(End/Home 越界后下次渲染归位)
505
+ const visible = lines.slice(start, start + viewH);
506
+ // pad 到 viewH(视口填满)
507
+ while (visible.length < viewH) visible.push("");
508
+ return visible;
509
+ }
510
+
511
+ /** 详情内容行(单一数据源:renderRightDetail 渲染 + detailScrollInfo 算长度都走这里)。 */
512
+ private buildDetailContent(record: SubagentRecord, width: number): string[] {
513
+ const t = this.theme;
514
+ const content: string[] = [];
515
+
516
+ // 任务提示词(最重要信息,置顶)。streaming 时 result 未产出,这是「它在干嘛」的唯一线索。
517
+ // detail 模式完整换行展示(word-wrap),不截断——task 是判断 subagent 行为的核心依据,
518
+ // 截断成省略号会丢信息。首行带 `task: ` 前缀,续行缩进对齐(缩进宽度 = 前缀可见宽度)。
519
+ const taskPrefix = "task: ";
520
+ const taskWrapWidth = Math.max(1, width - visibleWidth(taskPrefix));
521
+ const taskLines = wrapText(record.task, taskWrapWidth);
522
+ for (let i = 0; i < taskLines.length; i++) {
523
+ const lineText = taskLines[i];
524
+ if (i === 0) {
525
+ content.push(truncLine(t.fg("accent", `${taskPrefix}${lineText}`), width));
526
+ } else {
527
+ content.push(truncLine(t.fg("accent", `${" ".repeat(visibleWidth(taskPrefix))}${lineText}`), width));
528
+ }
529
+ }
530
+ if (taskLines.length === 0) {
531
+ content.push(truncLine(t.fg("accent", `${taskPrefix}(empty)`), width));
532
+ }
533
+
534
+ // 元数据:第 1 行 id + 状态 + turns + tokens
535
+ content.push(truncLine(
536
+ t.fg("dim", `${record.id} · ${record.mode} · ${record.status} · ${record.turns} turns · ${formatTokens(record.totalTokens)}`),
537
+ width,
538
+ ));
539
+ // 元数据:第 2 行 model + thinking(括号分组)
540
+ const metaParts: string[] = [];
541
+ if (record.model) metaParts.push(record.model);
542
+ if (record.thinkingLevel) metaParts.push(`thinking ${record.thinkingLevel}`);
543
+ content.push(metaParts.length > 0
544
+ ? truncLine(t.fg("dim", `(${metaParts.join(" · ")})`), width)
545
+ : "");
546
+
547
+ // 层级信息(方案 B):parent + children,让递归链可追溯。
548
+ // parent 不需外部数据;children 需查同 session 的 record(collectRecords 有磁盘缓存,开销可接受)。
549
+ const parentLabel = record.parentRecordId ? shortId(record.parentRecordId) : "(root)";
550
+ content.push(truncLine(t.fg("dim", `parent: ${parentLabel}`), width));
551
+ const childIds = this.service
552
+ .collectRecords(LIST_LIMIT)
553
+ .filter((r) => r.parentRecordId === record.id)
554
+ .map((r) => shortId(r.id));
555
+ content.push(truncLine(
556
+ t.fg("dim", `children: ${childIds.length > 0 ? childIds.join(", ") : "(none)"}`),
557
+ width,
558
+ ));
559
+
560
+ // 当前活动(仅内存 running 源;磁盘重建为 undefined)。streaming 可观测性。
561
+ if (record.currentActivity) {
562
+ content.push(truncLine(t.fg("accent", `▸ ${record.currentActivity.label}`), width));
563
+ }
564
+
565
+
566
+
567
+ content.push("");
568
+ content.push(truncLine(t.fg("accent", t.bold("── Output ──")), width));
569
+
570
+ // displayItems 从 turns[] 派生:完整 text + toolCall 序列(对齐 nicobailon getDisplayItems)。
571
+ // 替代旧 Event Log——eventLog 的 turn_end 曾丢弃 text 正文(现虽显示摘要但不完整),
572
+ // 导致详情看不到 subagent 的完整输出。displayItems 保留每 turn 的完整 text。
573
+ // running 时实时派生(流式 text 不丢失),终态从重建 turns[] 派生。
574
+ if (record.displayItems.length === 0 && record.eventLog.length > 0) {
575
+ // 旧数据兼容(displayItems 为空但 eventLog 有数据):回退 eventLog。
576
+ for (const entry of record.eventLog) {
577
+ content.push(truncLine(formatEventLine(entry, t), width));
578
+ }
579
+ } else if (record.displayItems.length === 0) {
580
+ content.push(truncLine(t.fg("dim", "(no output)"), width));
581
+ } else {
582
+ for (const item of record.displayItems) {
583
+ if (item.type === "text") {
584
+ // detail 模式:text 完整换行展示(word-wrap),不截断。subagent 的正文输出
585
+ // 可能很长(报告/分析),截断成省略号会丢信息——detail 有翻屏,完整性优先。
586
+ // wrapText 输入纯文本,每行单独着色 toolOutput。
587
+ const textLines = wrapText(item.text ?? "", width);
588
+ for (const tl of textLines) {
589
+ content.push(truncLine(t.fg("toolOutput", tl), width));
590
+ }
591
+ } else {
592
+ // toolCall:单行足够(name + args 摘要 + ✓/✗),truncLine 截断。
593
+ content.push(truncLine(formatDisplayItem(item, t), width));
594
+ }
595
+ }
596
+ }
597
+
598
+ if (record.result) {
599
+ content.push("");
600
+ content.push(truncLine(t.fg("accent", "Result:"), width));
601
+ // result 同样 word-wrap 完整展示(与 task/text 一致,detail 不截断)。
602
+ for (const l of wrapText(record.result, width)) {
603
+ content.push(truncLine(sanitizeLabel(l), width));
604
+ }
605
+ }
606
+ if (record.error) {
607
+ content.push("");
608
+ content.push(truncLine(t.fg("error", `Error: ${firstLine(record.error)}`), width));
609
+ }
610
+ if (record.sessionFile) {
611
+ content.push("");
612
+ content.push(truncLine(t.fg("dim", `session: ${record.sessionFile}`), width));
613
+ }
614
+
615
+ return content;
616
+ }
617
+
618
+ /** 详情内容总行数(供 detailScrollInfo 算 max,不重复生成)。 */
619
+ private detailContentLength(record: SubagentRecord): number {
620
+ // 复用 buildDetailContent 的行数:用足够大的宽度避免截断折行影响行数统计。
621
+ return this.buildDetailContent(record, DETAIL_LEN_PROBE_WIDTH).length;
622
+ }
623
+
624
+
625
+ /** dispose 时清理(Pi overlay 销毁时调用;wrappedDone 已清过,此处兜底防漏)。
626
+ * Pi SDK `showExtensionCustom.close()` 在 `done()` 后调 `component.dispose()`
627
+ * (pi-mono interactive-mode.ts 的 close 回调)——框架回调契约,非死代码。
628
+ * wrappedDone 已做 unsubscribe + clearInterval,此处幂等兜底。 */
629
+ // fallow 检测不到框架动态调用,标记 unused-class-member 是误报。
630
+ dispose(): void {
631
+ this.unsubscribe();
632
+ if (this.animTimer !== undefined) {
633
+ clearInterval(this.animTimer);
634
+ this.animTimer = undefined;
635
+ }
636
+ }
637
+ }
638
+
639
+ /** 计算 record 已耗时秒(endedAt 优先,否则 now - startedAt)。
640
+ * 委托给 Core 层共享 helper computeElapsedSeconds,消除发散。 */
641
+ function elapsedSec(r: SubagentRecord): number {
642
+ return computeElapsedSeconds(r);
643
+ }