@zhushanwen/pi-subagents 0.0.1

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 (37) hide show
  1. package/agents/context-builder.md +19 -0
  2. package/agents/oracle.md +19 -0
  3. package/agents/planner.md +19 -0
  4. package/agents/researcher.md +19 -0
  5. package/agents/reviewer.md +19 -0
  6. package/agents/scout.md +19 -0
  7. package/agents/worker.md +18 -0
  8. package/index.ts +1 -0
  9. package/package.json +59 -0
  10. package/src/commands/subagents.ts +78 -0
  11. package/src/core/agent-registry.ts +222 -0
  12. package/src/core/concurrency-pool.ts +78 -0
  13. package/src/core/event-bridge.ts +199 -0
  14. package/src/core/execution-record.ts +500 -0
  15. package/src/core/model-resolver.ts +206 -0
  16. package/src/core/output-collector.ts +118 -0
  17. package/src/core/path-encoding.ts +16 -0
  18. package/src/core/session-factory.ts +365 -0
  19. package/src/core/session-runner.ts +303 -0
  20. package/src/core/turn-limiter.ts +71 -0
  21. package/src/index.ts +104 -0
  22. package/src/runtime/config/config.ts +170 -0
  23. package/src/runtime/discovery-config.ts +135 -0
  24. package/src/runtime/execution/history-store.ts +196 -0
  25. package/src/runtime/execution/notifier.ts +209 -0
  26. package/src/runtime/execution/record-store.ts +280 -0
  27. package/src/runtime/model-config-service.ts +265 -0
  28. package/src/runtime/session-file-gc.ts +70 -0
  29. package/src/runtime/subagent-service.ts +549 -0
  30. package/src/tools/subagent-tool.ts +286 -0
  31. package/src/tui/bg-notify-render.ts +139 -0
  32. package/src/tui/config-wizard.ts +253 -0
  33. package/src/tui/format-helpers.ts +37 -0
  34. package/src/tui/format.ts +332 -0
  35. package/src/tui/list-view.ts +883 -0
  36. package/src/tui/tool-render.ts +467 -0
  37. package/src/types.ts +334 -0
@@ -0,0 +1,883 @@
1
+ // src/tui/list-view.ts
2
+ //
3
+ // /subagents list 全屏带框左右分屏 overlay。
4
+ // 左列:record 列表(状态图标 + agent + mode + 绝对时长)
5
+ // 右列:选中 record 详情(eventLog + result/error,可翻屏)
6
+ //
7
+ // 布局(margin:0 全屏覆盖,自画视觉边距盖住底下对话流):
8
+ // overlay 覆盖整个终端;框外留 1 行/1 列空白(applyPadding 画),框在内侧。
9
+ // ┌────────────────────────────┐ ← overlay 顶空白行(盖底下)
10
+ // │ ╭─ Subagents ───────────╮ │ ← 左 1 空格 + 框 + 右 1 空格
11
+ // │ │ filter: _ │ │
12
+ // │ ├─ Records ─┬─ Detail ───┤ │
13
+ // │ │ body ... │ body ... │ │
14
+ // │ ├────────────┴────────────┤ │
15
+ // │ │ ↑↓ 导航 ... │ │
16
+ // │ ╰─────────────────────────╯ │
17
+ // └────────────────────────────┘ ← overlay 底空白行
18
+ // (外层框线仅为示意,实际是空白行/空格列)
19
+ //
20
+ // 契约(ctx.ui.custom overlay,对照 pi-tui-development-guide.md §3.2):
21
+ // custom<void>((tui, theme, kb, done) => Component, {overlay:true, overlayOptions})
22
+ // Component: render(width):string[] + invalidate() + handleInput?(data)
23
+ //
24
+ // 关键避坑:
25
+ // 1. G-017 防叠加:模块级 activeView 单例,进入前 close(),factory 内 setActiveView
26
+ // 2. 导航只用方向键 matchesKey("up"|"down"),禁 j/k(避 filter 冲突)
27
+ // 3. overlay 退出 wrappedDone:幂等→标记→unsubscribe→clearAnimTimer→clearActiveView→done
28
+ // 4. sync record 不调 service.cancel(会污染状态),UI 层 syncCancelHint 提示
29
+ // 5. 不调 theme.bg(背景由 Pi overlay 容器施加),只 fg/bold
30
+ // 6. 所有行经 truncLine(ANSI 安全)
31
+ // 7. 边框不调 renderShell:"self"(守 default-shell / 无残影契约)
32
+ // 8. 不用 Pi 的 overlay margin(那是物理留白会透出底内容)——改 margin:0 全屏覆盖
33
+ // + applyPadding 自画视觉边距(顶底空白行 + 左右空格列),盖住底下对话流
34
+ // 9. 动画 setInterval(250ms) 安全:行数恒定(pad 到满屏),diff 只重画 spinner/elapsed
35
+
36
+ import type { Component } from "@earendil-works/pi-tui";
37
+ import { matchesKey } from "@earendil-works/pi-tui";
38
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
39
+
40
+ import type { SubagentService } from "../runtime/subagent-service.ts";
41
+ import type { SubagentRecord } from "../types.ts";
42
+ import {
43
+ firstLine,
44
+ formatElapsedSeconds,
45
+ formatEventLine,
46
+ formatTokens,
47
+ padToVisible,
48
+ sanitizeLabel,
49
+ segFillColored,
50
+ spinnerGlyph,
51
+ statusGlyph,
52
+ type ThemeLike,
53
+ truncLine,
54
+ } from "./format.ts";
55
+
56
+ // ============================================================
57
+ // 常量
58
+ // ============================================================
59
+
60
+ /** list 收集的 record 上限(足够覆盖一个活跃 session)。 */
61
+ const LIST_LIMIT = 100;
62
+
63
+ /** 左列占比。 */
64
+ const LEFT_COL_RATIO = 0.32;
65
+ /** 列最小宽度。 */
66
+ const COL_MIN_WIDTH = 20;
67
+ /** 列内最小内容宽度(兜底防负)。 */
68
+ const COL_INNER_MIN = 4;
69
+ /** 列内缩进("→ " 或 " " 前缀宽度)。 */
70
+ const COL_INDENT = 2;
71
+ /** 详情区 eventLog 翻屏步长(方向键单步)。 */
72
+ const DETAIL_SCROLL_STEP = 1;
73
+ /** 详情区 PgUp/PgDn 默认步长(无 viewport 信息时)。 */
74
+ const PAGE_SCROLL_DEFAULT = 10;
75
+ /** 右列预览的最近 eventLog 条数。 */
76
+ const PREVIEW_RECENT_LINES = 3;
77
+ /** 秒→毫秒换算。 */
78
+ const MS_PER_SECOND = 1000;
79
+
80
+ // ── 边框常量 ──
81
+ /** 左右边框字符宽度(│ x 2)。 */
82
+ const BORDER_WIDTH = 2;
83
+ /** 分屏模式下,框内**不滚动**的固定行数(顶框 1 + filter 1 + 分区线 1 + 底分区线 1 + footer 1 + 底框 1)。 */
84
+ const SPLIT_FIXED_LINES = 6;
85
+ /** 终端最小行数(低于此回退紧凑空列表框)。 */
86
+ const MIN_TERM_ROWS = 8;
87
+ /** terminal.rows 读不到时的兜底行数(防 duck-type 失败)。 */
88
+ const TERM_ROWS_FALLBACK = 24;
89
+ /** 自画视觉边距:框外左右各 1 列空白(盖住底下对话流)。 */
90
+ const PAD_COLS = 2;
91
+ /** 自画视觉边距:框外顶底各 1 行空白。 */
92
+ const PAD_ROWS = 2;
93
+ /** 内框最小宽(兜底防极窄终端)。 */
94
+ const MIN_INNER_WIDTH = 4;
95
+ /** 内框最小高(兜底防极矮终端)。 */
96
+ const MIN_INNER_ROWS = 4;
97
+ /** 详情内容总行数探测宽度(够大避免截断折行影响行数统计)。 */
98
+ const DETAIL_LEN_PROBE_WIDTH = 9999;
99
+ /** 垂直居中除数(floor(剩余/2))。 */
100
+ const VERT_CENTER_DIVISOR = 2;
101
+ /** overlay 动画刷新间隔(spinner 换帧 + elapsed 跳动)。同 tool-render.ts SPINNER_INTERVAL_MS。 */
102
+ const OVERLAY_REFRESH_MS = 250;
103
+ /** spinner 帧切换粒度(与 Date.now() 配合选帧)。 */
104
+ const SPINNER_FRAME_MS = 250;
105
+ /** 顶框嵌入标题(分屏模式)。 */
106
+ const TITLE_SPLIT = "Subagents";
107
+ /** 分屏分区线左/右嵌入标题。 */
108
+ const TITLE_LEFT = "Records";
109
+ const TITLE_RIGHT = "Detail";
110
+
111
+ /** list 视图内部状态。 */
112
+ export interface ViewState {
113
+ selectedIdx: number;
114
+ scrollOffset: number;
115
+ filterText: string;
116
+ detailMode: boolean;
117
+ disposed: boolean;
118
+ /** sync 取消提示(runtime 无法主动 abort sync,提示用户按对话流 Esc)。 */
119
+ syncCancelHint: boolean;
120
+ }
121
+
122
+ /** 详情翻屏上下文(processKey 算步长用)。 */
123
+ export interface DetailKeyContext {
124
+ viewportHeight: number;
125
+ contentLines?: number;
126
+ }
127
+
128
+ /** TUI 接口(duck-type:requestRender + terminal.rows)。
129
+ * terminal.rows 用于全屏框填满 + 详情翻屏步长(同 WorkflowsView.ts:104 cast)。 */
130
+ interface TuiLike {
131
+ requestRender(): void;
132
+ terminal: { rows: number };
133
+ }
134
+
135
+ /** 触发外部 notify 的回调(避免 list-view 直接依赖 ctx.ui)。 */
136
+ export type NotifyFn = (message: string, type?: "info" | "warning" | "error") => void;
137
+
138
+ // ============================================================
139
+ // G-017:模块级 overlay 单例(防叠加)
140
+ // ============================================================
141
+
142
+ /** 当前活动的 list overlay 句柄(null 表示无)。连按两次快捷键时先 close 前一个。 */
143
+ let activeView: { close: () => void } | null = null;
144
+
145
+ // ============================================================
146
+ // overlay 工厂
147
+ // ============================================================
148
+
149
+ /**
150
+ * 创建全屏左右分屏 overlay。
151
+ *
152
+ * ╔══════════════════════════════════════════════════════════════════╗
153
+ * ║ 1. G-017 防叠加:activeView?.close() ║
154
+ * ║ 2. ctx.ui.custom((tui, theme, kb, done) => { ║
155
+ * ║ unsubscribe = service.onChange(() => tui.requestRender()) ║
156
+ * ║ activeView = { close: wrappedDone } ║
157
+ * ║ return new SubagentsListComponent(...) ║
158
+ * ║ }, { overlay:true, overlayOptions:{margin:0, width:"100%"}}) ║
159
+ * ║ ║
160
+ * ║ directId 不在 records 中 → notify 警告,仍打开列表 ║
161
+ * ╚══════════════════════════════════════════════════════════════════╝
162
+ */
163
+ export async function createSubagentsView(
164
+ service: SubagentService,
165
+ theme: ThemeLike,
166
+ ctx: ExtensionContext,
167
+ directId?: string,
168
+ ): Promise<void> {
169
+ // G-017:先关前一个 overlay
170
+ if (activeView) {
171
+ activeView.close();
172
+ activeView = null;
173
+ }
174
+
175
+ const notify: NotifyFn = (msg, type) => ctx.ui.notify(msg, type);
176
+
177
+ // directId 提示
178
+ if (directId) {
179
+ const all = service.collectRecords(LIST_LIMIT);
180
+ if (!all.some((r) => r.id === directId)) {
181
+ notify(`No record found for id "${directId}", showing all`, "warning");
182
+ }
183
+ }
184
+
185
+ await ctx.ui.custom<void>(
186
+ (tui, _theme, _kb, done) => {
187
+ // duck-type cast:读 terminal.rows 做满屏填高 + 详情翻屏步长
188
+ const tuiLike = tui as TuiLike;
189
+ const state: ViewState = {
190
+ selectedIdx: 0,
191
+ scrollOffset: 0,
192
+ filterText: "",
193
+ detailMode: false,
194
+ disposed: false,
195
+ syncCancelHint: false,
196
+ };
197
+
198
+ // 订阅 store 变化 → requestRender(store 驱动重渲)
199
+ const unsubscribe = service.onChange(() => {
200
+ if (!state.disposed) tuiLike.requestRender();
201
+ });
202
+
203
+ // directId 命中 → 进详情模式(右侧就地展开,底部对齐)
204
+ if (directId) {
205
+ const records = service.collectRecords(LIST_LIMIT);
206
+ const idx = records.findIndex((r) => r.id === directId);
207
+ if (idx >= 0) {
208
+ state.selectedIdx = idx;
209
+ state.detailMode = true;
210
+ state.scrollOffset = Number.MAX_SAFE_INTEGER; // 底部对齐,render clamp 收敛
211
+ }
212
+ }
213
+
214
+ const component = new SubagentsListComponent(service, theme, tuiLike, state, unsubscribe, notify);
215
+
216
+ // 动画 timer:有 running record 时定期 invalidate + requestRender,
217
+ // 让 spinner 丝滑换帧、elapsed 实时跳动(行数恒定,安全——对照
218
+ // tool-render.ts 的 setInterval 模式 + dev guide §8160a5d13 安全分析)。
219
+ const animTimer = setInterval(() => {
220
+ if (state.disposed) return;
221
+ if (!component.hasRunning()) return; // 无 running 不浪费刷新
222
+ component.invalidate();
223
+ tuiLike.requestRender();
224
+ }, OVERLAY_REFRESH_MS);
225
+ component.setAnimTimer(animTimer);
226
+
227
+ // wrappedDone(dev guide §4 顺序:幂等→标记→unsubscribe→clearAnimTimer→clearActiveView→done)
228
+ const wrappedDone = () => {
229
+ if (state.disposed) return; // 幂等
230
+ state.disposed = true; // ① 标记
231
+ unsubscribe(); // ② 解订 store 事件
232
+ clearInterval(animTimer); // ③ 清动画 timer
233
+ activeView = null; // ④ 清 G-017 句柄
234
+ done(undefined); // ⑤ 框架 done(触发 overlay 销毁)
235
+ };
236
+ component.setCloseFn(wrappedDone);
237
+ activeView = { close: wrappedDone };
238
+
239
+ return component;
240
+ },
241
+ {
242
+ overlay: true,
243
+ overlayOptions: {
244
+ anchor: "center" as const,
245
+ width: "100%",
246
+ maxHeight: "100%",
247
+ // margin:0 → overlay 覆盖整个终端(不留物理空白)。
248
+ // 视觉边距由 buildLines 自画(顶底空白行 + 左右空格列),盖住底下对话流。
249
+ // Pi 的 margin 是「物理留白透出底内容」,这里不能用。
250
+ margin: 0,
251
+ },
252
+ },
253
+ );
254
+ }
255
+
256
+ // ============================================================
257
+ // 按键处理(纯函数,可单测)
258
+ // ============================================================
259
+
260
+ /**
261
+ * 按键处理。两阶段焦点(detailMode 控制):
262
+ *
263
+ * ╔══════════════════════════════════════════════════════════════════╗
264
+ // ║ 阶段 1(list 焦点,detailMode=false): ║
265
+ // ║ Esc 退出(有 filter 先清)/ ↑↓ 导航左列 / Enter 进阶段 2 ║
266
+ // ║ Backspace 删 filter / 可打印字符直接 filter ║
267
+ // ║ ║
268
+ // ║ 阶段 2(detail 焦点,detailMode=true):左侧锚定,滚右侧详情 ║
269
+ // ║ Esc 返回阶段 1 / ↑↓ PgUp/PgDn Home End 滚右侧 eventLog ║
270
+ // ║ x 停止:background → service.cancel(id)(真正 abort) ║
271
+ // ║ sync → 仅 syncCancelHint(runtime 无法主动 abort sync) ║
272
+ // ╚══════════════════════════════════════════════════════════════════╝
273
+ *
274
+ * 返回 KeyResult:changed 表示状态变更需重绘;exit 表示调用方应关闭 overlay。
275
+ * 二者正交——Esc 在阶段 1 无 filter 时 changed=false + exit=true。
276
+ */
277
+ export interface KeyResult {
278
+ /** 状态变更,需 invalidate + requestRender。 */
279
+ changed: boolean;
280
+ /** 调用方应调用 closeFn 关闭 overlay。 */
281
+ exit: boolean;
282
+ }
283
+
284
+ export function processKey(
285
+ data: string,
286
+ records: SubagentRecord[],
287
+ state: ViewState,
288
+ selected: SubagentRecord | null,
289
+ service: SubagentService | null,
290
+ detailCtx: DetailKeyContext | undefined,
291
+ notify: NotifyFn | undefined,
292
+ ): KeyResult {
293
+ // ── 阶段 2(detail 焦点,detailMode=true):左侧锚定,滚右侧详情 ──
294
+ if (state.detailMode) {
295
+ if (matchesKey(data, "escape")) {
296
+ state.detailMode = false;
297
+ state.scrollOffset = 0;
298
+ state.syncCancelHint = false;
299
+ return { changed: true, exit: false };
300
+ }
301
+ if (matchesKey(data, "up")) {
302
+ state.scrollOffset = Math.max(0, state.scrollOffset - DETAIL_SCROLL_STEP);
303
+ return { changed: true, exit: false };
304
+ }
305
+ if (matchesKey(data, "down")) {
306
+ const max = detailScrollMax(detailCtx);
307
+ state.scrollOffset = Math.min(max, state.scrollOffset + DETAIL_SCROLL_STEP);
308
+ return { changed: true, exit: false };
309
+ }
310
+ if (matchesKey(data, "pageUp")) {
311
+ const step = detailCtx?.viewportHeight ?? PAGE_SCROLL_DEFAULT;
312
+ state.scrollOffset = Math.max(0, state.scrollOffset - step);
313
+ return { changed: true, exit: false };
314
+ }
315
+ if (matchesKey(data, "pageDown")) {
316
+ const step = detailCtx?.viewportHeight ?? PAGE_SCROLL_DEFAULT;
317
+ const max = detailScrollMax(detailCtx);
318
+ state.scrollOffset = Math.min(max, state.scrollOffset + step);
319
+ return { changed: true, exit: false };
320
+ }
321
+ if (matchesKey(data, "home")) {
322
+ state.scrollOffset = 0;
323
+ return { changed: true, exit: false };
324
+ }
325
+ if (matchesKey(data, "end")) {
326
+ state.scrollOffset = detailScrollMax(detailCtx);
327
+ return { changed: true, exit: false };
328
+ }
329
+ // x:停止当前 record
330
+ if (data === "x" && selected) {
331
+ const changed = handleCancel(selected, service, state, notify);
332
+ return { changed, exit: false };
333
+ }
334
+ return { changed: false, exit: false };
335
+ }
336
+
337
+ // ── 阶段 1(list 焦点,detailMode=false):↑↓ 导航左列 ──
338
+ if (matchesKey(data, "escape")) {
339
+ // 有 filter 先清(changed);无 filter → 退出 overlay(exit)
340
+ if (state.filterText.length > 0) {
341
+ state.filterText = "";
342
+ state.selectedIdx = 0;
343
+ return { changed: true, exit: false };
344
+ }
345
+ return { changed: false, exit: true };
346
+ }
347
+ if (matchesKey(data, "up")) {
348
+ state.selectedIdx = Math.max(0, state.selectedIdx - 1);
349
+ return { changed: true, exit: false };
350
+ }
351
+ if (matchesKey(data, "down")) {
352
+ state.selectedIdx = Math.min(Math.max(0, records.length - 1), state.selectedIdx + 1);
353
+ return { changed: true, exit: false };
354
+ }
355
+ if (matchesKey(data, "enter") || matchesKey(data, "return")) {
356
+ if (selected) {
357
+ state.detailMode = true;
358
+ // 底部对齐:设大值,renderRightDetail 的 clamp 收敛到 max(最新在底,向上看历史)
359
+ state.scrollOffset = Number.MAX_SAFE_INTEGER;
360
+ state.syncCancelHint = false;
361
+ return { changed: true, exit: false };
362
+ }
363
+ return { changed: false, exit: false };
364
+ }
365
+ if (matchesKey(data, "backspace")) {
366
+ if (state.filterText.length > 0) {
367
+ state.filterText = state.filterText.slice(0, -1);
368
+ state.selectedIdx = 0;
369
+ return { changed: true, exit: false };
370
+ }
371
+ return { changed: false, exit: false };
372
+ }
373
+ // 可打印字符 → filter(单字符 ASCII 可见区)
374
+ if (data.length === 1 && data >= " " && data <= "~") {
375
+ state.filterText += data;
376
+ state.selectedIdx = 0;
377
+ return { changed: true, exit: false };
378
+ }
379
+ return { changed: false, exit: false };
380
+ }
381
+
382
+ /** filter 过滤 + 排序(纯函数,可单测)。 */
383
+ export function applyFilter(records: SubagentRecord[], filterText: string): SubagentRecord[] {
384
+ const q = filterText.trim().toLowerCase();
385
+ if (!q) return records;
386
+ return records.filter((r) => {
387
+ return (
388
+ r.agent.toLowerCase().includes(q) ||
389
+ r.status.toLowerCase().includes(q) ||
390
+ r.mode.toLowerCase().includes(q) ||
391
+ r.id.toLowerCase().includes(q)
392
+ );
393
+ });
394
+ }
395
+
396
+ // ============================================================
397
+ // Component 实现
398
+ // ============================================================
399
+
400
+ /**
401
+ * 全屏带框左右分屏 list 组件。
402
+ *
403
+ * 不缓存行(records 每次 render 都从 service.collectRecords 拉最新——保证 store 变化后刷新)。
404
+ * 缓存的是「上次 render 的 width×rows」(用于 invalidate 后强制重建)。
405
+ */
406
+ class SubagentsListComponent implements Component {
407
+ private cachedKey: string | undefined;
408
+ private cachedLines: string[] | undefined;
409
+ private closeFn: () => void = () => {};
410
+ /** 动画 timer 句柄(dispose 兜底清理)。 */
411
+ private animTimer: ReturnType<typeof setInterval> | undefined;
412
+
413
+ constructor(
414
+ private readonly service: SubagentService,
415
+ private readonly theme: ThemeLike,
416
+ private readonly tui: TuiLike,
417
+ private readonly state: ViewState,
418
+ private readonly unsubscribe: () => void,
419
+ private readonly notify: NotifyFn,
420
+ ) {}
421
+
422
+ setCloseFn(fn: () => void): void {
423
+ this.closeFn = fn;
424
+ }
425
+
426
+ /** 注入动画 timer 句柄(dispose 兜底清理用)。 */
427
+ setAnimTimer(timer: ReturnType<typeof setInterval>): void {
428
+ this.animTimer = timer;
429
+ }
430
+
431
+ /** 是否有 running record(动画 timer 据此决定是否刷新)。 */
432
+ hasRunning(): boolean {
433
+ return this.service.collectRecords(LIST_LIMIT).some((r) => r.status === "running");
434
+ }
435
+
436
+ invalidate(): void {
437
+ this.cachedKey = undefined;
438
+ this.cachedLines = undefined;
439
+ }
440
+
441
+ render(width: number): string[] {
442
+ const rows = this.termRows();
443
+ const key = `${width}x${rows}`;
444
+ if (key === this.cachedKey && this.cachedLines) return this.cachedLines;
445
+ const lines = this.buildLines(width, rows);
446
+ this.cachedKey = key;
447
+ this.cachedLines = lines;
448
+ return lines;
449
+ }
450
+
451
+ handleInput(data: string): void {
452
+ if (this.state.disposed) return;
453
+
454
+ const records = applyFilter(this.service.collectRecords(LIST_LIMIT), this.state.filterText);
455
+ const selected = records[this.state.selectedIdx] ?? null;
456
+ // 详情翻屏上下文:视口高 = 右侧 body 高(内框高 - SPLIT_FIXED_LINES),
457
+ // contentLines = 详情内容总行数(含元数据/段头/eventLog/result/error,单一数据源)。
458
+ // 与 renderRightDetail 的 viewH + max 计算保持一致。
459
+ const innerRows = Math.max(MIN_INNER_ROWS, this.termRows() - PAD_ROWS);
460
+ const bodyH = Math.max(1, innerRows - SPLIT_FIXED_LINES);
461
+ const detailCtx: DetailKeyContext = {
462
+ viewportHeight: bodyH,
463
+ contentLines: selected ? this.detailContentLength(selected) : 0,
464
+ };
465
+
466
+ const result = processKey(data, records, this.state, selected, this.service, detailCtx, this.notify);
467
+
468
+ if (result.exit) {
469
+ this.closeFn();
470
+ return;
471
+ }
472
+ if (result.changed) {
473
+ this.invalidate();
474
+ this.tui.requestRender();
475
+ }
476
+ }
477
+
478
+ /** 安全读 terminal.rows(兜底防 duck-type 失败)。 */
479
+ private termRows(): number {
480
+ const rows = this.tui.terminal?.rows;
481
+ return typeof rows === "number" && rows > 0 ? rows : TERM_ROWS_FALLBACK;
482
+ }
483
+
484
+ // ── 内部:渲染 ──────────────────────────────────────────
485
+
486
+ /**
487
+ * 构建行数组(全屏覆盖 + 自画视觉边距)。
488
+ *
489
+ * width = render 收到的全屏宽(margin:0 → termCols,overlay 覆盖整个终端)
490
+ * rows = terminal.rows(满屏高)
491
+ *
492
+ * overlay 不用 Pi 的 margin(那是物理留白会透出底下内容),改 margin:0 全屏覆盖,
493
+ * 自己在框外加 1 行/1 列空白(盖住底下的对话流):
494
+ * - 每行:` ` + 框行 + ` `(左右各 1 空格视觉边距)
495
+ * - 顶/底:各 1 行全宽空白
496
+ * - 内框宽 = width - 2(左右边距),内框高 = rows - 2(顶底边距)
497
+ *
498
+ * 分三个分支(基于内框尺寸):
499
+ * 1. 终端太矮(< MIN_TERM_ROWS)→ 紧凑提示,不画框
500
+ * 2. 空列表 → 紧凑小框(不填满全屏)
501
+ * 3. 有 records → 分屏满屏框(detailMode 控制右侧预览 vs 完整翻屏,不再切全屏页)
502
+ */
503
+ private buildLines(width: number, rows: number): string[] {
504
+ // 内框尺寸(减去左右 1 列 + 顶底 1 行的视觉边距)
505
+ const innerWidth = Math.max(MIN_INNER_WIDTH, width - PAD_COLS);
506
+ const innerRows = Math.max(MIN_INNER_ROWS, rows - PAD_ROWS);
507
+
508
+ const allRecords = this.service.collectRecords(LIST_LIMIT);
509
+ const records = applyFilter(allRecords, this.state.filterText);
510
+
511
+ // 先在内框尺寸下生成框行
512
+ let innerLines: string[];
513
+ if (rows < MIN_TERM_ROWS) {
514
+ innerLines = this.renderTooSmall(innerWidth);
515
+ } else if (allRecords.length === 0) {
516
+ // 真正的空列表(无任何 subagent)→ 紧凑小框
517
+ innerLines = this.renderEmptyBox(innerWidth);
518
+ } else {
519
+ // 有 records(即使 filter 无匹配,也保留分屏布局——只清空左右内容区)
520
+ this.state.selectedIdx = Math.min(this.state.selectedIdx, Math.max(0, records.length - 1));
521
+ innerLines = this.renderSplitBox(records, innerWidth, innerRows);
522
+ }
523
+
524
+ return this.applyPadding(innerLines, width, rows);
525
+ }
526
+
527
+ /**
528
+ * 给内框行套视觉边距并填满全屏:顶/底各加空白行直到满屏高,每行加左右 1 空格。
529
+ * 这些空白是 overlay 自己画的(盖住底下对话流),区别于 Pi 的物理 margin(透出底内容)。
530
+ * 紧凑框(空列表/太矮)也会被空白填满全屏——保证整个终端被 overlay 覆盖。
531
+ */
532
+ private applyPadding(innerLines: string[], width: number, rows: number): string[] {
533
+ const blank = " ".repeat(width);
534
+ // 左右各加 1 空格的边距行(内框行 visibleWidth 已 = width - 2)
535
+ const padLine = (line: string) => ` ${line} `;
536
+ const result: string[] = [];
537
+ // 顶部空白填满(紧凑框时把框垂直居中)
538
+ const topPad = Math.max(1, Math.floor((rows - innerLines.length) / VERT_CENTER_DIVISOR));
539
+ for (let i = 0; i < topPad; i++) result.push(blank);
540
+ for (const line of innerLines) result.push(padLine(line));
541
+ // 底部空白填满到 rows
542
+ while (result.length < rows) result.push(blank);
543
+ return result;
544
+ }
545
+
546
+ // ── 边框着色 helper(统一 borderMuted,避 ANSI 嵌套失色)──
547
+
548
+ /** 着色框线字符(borderMuted)。所有 ╭╮╰╯├┤┬┴─│ 统一走这里。 */
549
+ private b(s: string): string {
550
+ return this.theme.fg("borderMuted", s);
551
+ }
552
+ /** 着色单字符填充用的 `─`(供 segFillColored 的 fillStyled)。 */
553
+ private dash(): string {
554
+ return this.theme.fg("borderMuted", "─");
555
+ }
556
+ /** 满宽 `─` 填充串(borderMuted)。n 次单字符着色,ANSI 自然延续。 */
557
+ private dashes(n: number): string {
558
+ return this.dash().repeat(Math.max(0, n));
559
+ }
560
+ /** 顶/底框行:`╭` + 着色标题填充 + `╮`(或 ╰╯)。每段独立着色,无嵌套。 */
561
+ private titleBorder(left: string, titleStyled: string, right: string, contentWidth: number): string {
562
+ return this.b(left) + segFillColored(titleStyled, this.dash(), contentWidth) + this.b(right);
563
+ }
564
+ /** 纯线顶/底框(无标题):`╭` + `─`×W + `╮`。 */
565
+ private plainBorder(left: string, right: string, contentWidth: number): string {
566
+ return this.b(left) + this.dashes(contentWidth) + this.b(right);
567
+ }
568
+ /** 内容行墙:`│` + 内容(pad 到 contentWidth) + `│`,墙字符 borderMuted。 */
569
+ private walled(content: string, contentWidth: number): string {
570
+ return `${this.b("│")}${padToVisible(content, contentWidth)}${this.b("│")}`;
571
+ }
572
+
573
+ // ── 分支 1:终端太小 ──────────────────────────────────
574
+
575
+ private renderTooSmall(width: number): string[] {
576
+ const t = this.theme;
577
+ const contentWidth = Math.max(1, width - BORDER_WIDTH);
578
+ const msg = t.fg("warning", `Terminal too small (need >=${MIN_TERM_ROWS} rows)`);
579
+ return [
580
+ this.plainBorder("╭", "╮", contentWidth),
581
+ this.walled(padToVisible(msg, contentWidth), contentWidth),
582
+ this.plainBorder("╰", "╯", contentWidth),
583
+ ];
584
+ }
585
+
586
+ // ── 分支 2:空列表紧凑框 ──────────────────────────────
587
+
588
+ private renderEmptyBox(width: number): string[] {
589
+ const t = this.theme;
590
+ const contentWidth = Math.max(1, width - BORDER_WIDTH);
591
+ const title = t.fg("accent", t.bold(` ${TITLE_SPLIT} `));
592
+ return [
593
+ this.titleBorder("╭", title, "╮", contentWidth),
594
+ this.walled("", contentWidth),
595
+ this.walled(truncLine(t.fg("dim", "(no subagent records)"), contentWidth), contentWidth),
596
+ this.walled("", contentWidth),
597
+ this.walled(truncLine(t.fg("dim", "Esc to exit"), contentWidth), contentWidth),
598
+ this.plainBorder("╰", "╯", contentWidth),
599
+ ];
600
+ }
601
+
602
+ // ── 分支 3:分屏满屏框(detailMode 控制右侧预览 vs 完整翻屏)──
603
+
604
+ private renderSplitBox(records: SubagentRecord[], width: number, rows: number): string[] {
605
+ const t = this.theme;
606
+ const contentWidth = Math.max(1, width - BORDER_WIDTH);
607
+ // 左右列宽:左按比例,右占余下(减去分隔符 1 列)
608
+ const leftWidth = Math.max(COL_MIN_WIDTH, Math.floor(contentWidth * LEFT_COL_RATIO));
609
+ const rightWidth = Math.max(COL_MIN_WIDTH, contentWidth - leftWidth - 1);
610
+ const sep = this.b("│");
611
+
612
+ // 满屏可用 body 高 = 内框高 - 固定行(顶框/filter/分区线/底分区线/footer/底框 = 6)
613
+ // rows 参数已是内框高(顶底空白边距已在 buildLines 扣除)。
614
+ const bodyH = Math.max(1, rows - SPLIT_FIXED_LINES);
615
+
616
+ const selected = records[this.state.selectedIdx] ?? null;
617
+ const inDetail = this.state.detailMode; // 阶段 2:右侧滚动焦点
618
+
619
+ const lines: string[] = [];
620
+
621
+ // 顶框(嵌入标题,分段着色)
622
+ lines.push(this.titleBorder("╭", t.fg("accent", t.bold(` ${TITLE_SPLIT} `)), "╮", contentWidth));
623
+
624
+ // filter 行(阶段 2 时隐藏 filter 提示,显示锚定提示)
625
+ const filterLine = inDetail
626
+ ? t.fg("dim", `Pinned: ${selected?.agent ?? ""} · Esc to return to list`)
627
+ : (this.state.filterText
628
+ ? `${t.fg("dim", "filter: ")}${t.bold(this.state.filterText)}${t.fg("accent", "_")}`
629
+ : `${t.fg("dim", "filter: ")}${t.fg("accent", "_")}`);
630
+ lines.push(this.walled(padToVisible(truncLine(filterLine, contentWidth), contentWidth), contentWidth));
631
+
632
+ // 分区线(嵌入左/右标题,分段着色)
633
+ const leftTitleStyled = t.fg("accent", t.bold(` ${TITLE_LEFT} `));
634
+ const rightTitleStyled = inDetail
635
+ ? t.fg("accent", t.bold(` ${TITLE_RIGHT}${this.detailScrollInfo(selected, bodyH)} `))
636
+ : t.fg("accent", t.bold(` ${TITLE_RIGHT} `));
637
+ lines.push(
638
+ this.b("├") + segFillColored(leftTitleStyled, this.dash(), leftWidth)
639
+ + this.b("┬") + segFillColored(rightTitleStyled, this.dash(), rightWidth) + this.b("┤"),
640
+ );
641
+
642
+ // body:左列 record 列表 + 右列(预览 or 完整翻屏)
643
+ let leftLines: string[];
644
+ let rightLines: string[];
645
+ if (records.length === 0) {
646
+ // filter 无匹配:保留分屏布局,左右都显示提示
647
+ leftLines = [t.fg("dim", `(no match for "${this.state.filterText}")`)];
648
+ rightLines = [t.fg("dim", "(no record selected)")];
649
+ } else {
650
+ leftLines = this.renderLeftColumn(records, leftWidth);
651
+ rightLines = inDetail
652
+ ? this.renderRightDetail(selected, rightWidth, bodyH)
653
+ : this.renderRightPreview(selected, rightWidth);
654
+ }
655
+ const bodyRows = Math.max(leftLines.length, rightLines.length, bodyH);
656
+ for (let i = 0; i < bodyRows; i++) {
657
+ const l = leftLines[i] ?? "";
658
+ const r = rightLines[i] ?? "";
659
+ const row = `${padToVisible(truncLine(l, leftWidth), leftWidth)}${sep}${padToVisible(truncLine(r, rightWidth), rightWidth)}`;
660
+ lines.push(this.walled(padToVisible(row, contentWidth), contentWidth));
661
+ }
662
+
663
+ // 底分区线
664
+ lines.push(this.b("├") + this.dashes(leftWidth) + this.b("┴") + this.dashes(rightWidth) + this.b("┤"));
665
+
666
+ // footer(双文案)
667
+ const footer = inDetail
668
+ ? t.fg("dim", "Esc back to list · Up/Dn/PgUp/PgDn/Home/End scroll detail" + this.cancelHint(selected))
669
+ : t.fg("dim", "Up/Dn navigate · Enter detail · type to filter · Esc exit");
670
+ lines.push(this.walled(padToVisible(truncLine(footer, contentWidth), contentWidth), contentWidth));
671
+
672
+ // 底框
673
+ lines.push(this.plainBorder("╰", "╯", contentWidth));
674
+
675
+ return lines;
676
+ }
677
+
678
+ /** 详情模式滚动位置指示(嵌入分区线标题),如 "Detail (5-12/30)"。无内容则空。 */
679
+ private detailScrollInfo(record: SubagentRecord | null, viewH: number): string {
680
+ if (!record) return "";
681
+ const contentLen = this.detailContentLength(record);
682
+ if (contentLen <= viewH) return ""; // 内容一屏装下,不显示
683
+ const max = Math.max(0, contentLen - viewH);
684
+ const start = Math.max(0, Math.min(this.state.scrollOffset, max));
685
+ const end = Math.min(start + viewH, contentLen);
686
+ return ` (${start + 1}-${end}/${contentLen})`;
687
+ }
688
+
689
+ /** footer 的取消提示(仅 running 时显示)。 */
690
+ private cancelHint(record: SubagentRecord | null): string {
691
+ if (!record || record.status !== "running") return "";
692
+ return record.mode === "background" ? " · x stop" : " · x stop (hint)";
693
+ }
694
+
695
+ /** 左列:record 列表。阶段 2(detailMode)时非锚定行 dim,锚定行用 ▶。 */
696
+ private renderLeftColumn(records: SubagentRecord[], width: number): string[] {
697
+ const t = this.theme;
698
+ const innerWidth = Math.max(COL_INNER_MIN, width - COL_INDENT);
699
+ const inDetail = this.state.detailMode;
700
+ // spinner 当前帧(Date.now() 驱动;animTimer 定期 invalidate → render 重选帧)
701
+ const spinFrame = spinnerGlyph(Math.floor(Date.now() / SPINNER_FRAME_MS));
702
+ return records.map((r, i) => {
703
+ const selected = i === this.state.selectedIdx;
704
+ const glyph = statusGlyph(r.status);
705
+ const icon = glyph.icon ?? spinFrame;
706
+ const iconStr = t.fg(glyph.color, icon);
707
+ const modeTag = r.mode === "background" ? "bg" : "sync";
708
+ const dur = formatElapsedSeconds(elapsedSec(r));
709
+ const label = `${iconStr} ${r.agent} ${t.fg("dim", modeTag)} ${t.fg("dim", dur)}`;
710
+ // 阶段 2:锚定行 accent + ▶;其余行 dim。阶段 1:选中 accent + →,其余正常。
711
+ const content = inDetail
712
+ ? (selected ? t.fg("accent", label) : t.fg("dim", label))
713
+ : (selected ? t.fg("accent", label) : label);
714
+ const prefix = selected ? (inDetail ? "▶ " : "→ ") : " ";
715
+ return `${prefix}${truncLine(content, innerWidth)}`;
716
+ });
717
+ }
718
+
719
+ /** 右列:选中 record 的预览(阶段 1)。 */
720
+ private renderRightPreview(record: SubagentRecord | null, width: number): string[] {
721
+ const t = this.theme;
722
+ if (!record) return [t.fg("dim", "(no record selected)")];
723
+
724
+ const lines: string[] = [];
725
+ lines.push(truncLine(`${t.bold(record.agent)} ${t.fg("dim", `· ${record.model}`)}`, width));
726
+ lines.push(truncLine(
727
+ t.fg("dim", `${record.status} · ${record.turns} turns · ${formatTokens(record.totalTokens)} · ${formatElapsedSeconds(elapsedSec(record))}`),
728
+ width,
729
+ ));
730
+ lines.push("");
731
+
732
+ const recent = record.eventLog.slice(-PREVIEW_RECENT_LINES);
733
+ if (recent.length === 0) {
734
+ lines.push(truncLine(t.fg("dim", "(no event log — from history)"), width));
735
+ } else {
736
+ for (const entry of recent) {
737
+ lines.push(truncLine(formatEventLine(entry, t), width));
738
+ }
739
+ }
740
+
741
+ lines.push("");
742
+ lines.push(truncLine(t.fg("dim", "Enter for full detail"), width));
743
+ return lines;
744
+ }
745
+
746
+ /**
747
+ * 右列:完整详情(阶段 2,detailMode)。完整 eventLog + result/error + sessionFile,
748
+ * scrollOffset 翻屏。底部对齐(最新在底,向上看历史)——Enter 进阶段 2 时 scrollOffset=max。
749
+ *
750
+ * 内容行生成与 detailContentLength 共用 buildDetailContent(单一数据源)。
751
+ */
752
+ private renderRightDetail(record: SubagentRecord | null, width: number, viewH: number): string[] {
753
+ const t = this.theme;
754
+ if (!record) return [t.fg("dim", "(no record selected)")];
755
+
756
+ const content = this.buildDetailContent(record, width);
757
+ // 翻屏(底部对齐:max = content.length - viewH)
758
+ const max = Math.max(0, content.length - viewH);
759
+ if (this.state.scrollOffset > max) this.state.scrollOffset = max;
760
+ const start = Math.max(0, Math.min(this.state.scrollOffset, max));
761
+ this.state.scrollOffset = start; // 回写收敛(End/Home 越界后下次渲染归位)
762
+ const visible = content.slice(start, start + viewH);
763
+ // pad 到 viewH(视口填满)
764
+ while (visible.length < viewH) visible.push("");
765
+ return visible;
766
+ }
767
+
768
+ /** 详情内容行(单一数据源:renderRightDetail 渲染 + detailScrollInfo 算长度都走这里)。 */
769
+ private buildDetailContent(record: SubagentRecord, width: number): string[] {
770
+ const t = this.theme;
771
+ const content: string[] = [];
772
+
773
+ // 元数据:第 1 行 id + 状态 + turns + tokens
774
+ content.push(truncLine(
775
+ t.fg("dim", `${record.id} · ${record.mode} · ${record.status} · ${record.turns} turns · ${formatTokens(record.totalTokens)}`),
776
+ width,
777
+ ));
778
+ // 元数据:第 2 行 model + thinking(括号分组)
779
+ const metaParts: string[] = [];
780
+ if (record.model) metaParts.push(record.model);
781
+ if (record.thinkingLevel) metaParts.push(`thinking ${record.thinkingLevel}`);
782
+ content.push(metaParts.length > 0
783
+ ? truncLine(t.fg("dim", `(${metaParts.join(" · ")})`), width)
784
+ : "");
785
+
786
+ // syncCancelHint
787
+ if (this.state.syncCancelHint) {
788
+ content.push("");
789
+ content.push(truncLine(t.fg("warning", "Cannot stop a sync subagent here — press Esc in the chat to abort"), width));
790
+ }
791
+
792
+ content.push("");
793
+ content.push(truncLine(t.fg("accent", t.bold("── Event Log ──")), width));
794
+
795
+ if (record.eventLog.length === 0) {
796
+ content.push(truncLine(t.fg("dim", "(no event log — from history)"), width));
797
+ } else {
798
+ for (const entry of record.eventLog) {
799
+ content.push(truncLine(formatEventLine(entry, t), width));
800
+ }
801
+ }
802
+
803
+ if (record.result) {
804
+ content.push("");
805
+ content.push(truncLine(t.fg("accent", "Result:"), width));
806
+ for (const l of record.result.split("\n")) {
807
+ content.push(truncLine(sanitizeLabel(l), width));
808
+ }
809
+ }
810
+ if (record.error) {
811
+ content.push("");
812
+ content.push(truncLine(t.fg("error", `Error: ${firstLine(record.error)}`), width));
813
+ }
814
+ if (record.sessionFile) {
815
+ content.push("");
816
+ content.push(truncLine(t.fg("dim", `session: ${record.sessionFile}`), width));
817
+ }
818
+
819
+ return content;
820
+ }
821
+
822
+ /** 详情内容总行数(供 detailScrollInfo 算 max,不重复生成)。 */
823
+ private detailContentLength(record: SubagentRecord): number {
824
+ // 复用 buildDetailContent 的行数:用足够大的宽度避免截断折行影响行数统计。
825
+ return this.buildDetailContent(record, DETAIL_LEN_PROBE_WIDTH).length;
826
+ }
827
+
828
+
829
+ /** dispose 时清理(Pi overlay 销毁时调用;wrappedDone 已清过,此处兜底防漏)。 */
830
+ dispose(): void {
831
+ this.unsubscribe();
832
+ if (this.animTimer !== undefined) {
833
+ clearInterval(this.animTimer);
834
+ this.animTimer = undefined;
835
+ }
836
+ }
837
+ }
838
+
839
+ // ============================================================
840
+ // 内部辅助
841
+ // ============================================================
842
+
843
+ /** 计算 record 已耗时秒(endedAt 优先,否则 now - startedAt)。 */
844
+ function elapsedSec(r: SubagentRecord): number {
845
+ const end = r.endedAt ?? Date.now();
846
+ return Math.max(0, Math.floor((end - r.startedAt) / MS_PER_SECOND));
847
+ }
848
+
849
+ /** 详情翻屏最大 offset(contentLines - viewportHeight,兜底 0)。
850
+ * 与 renderRightDetail 的 max 计算保持一致(content.length - viewH)。 */
851
+ function detailScrollMax(detailCtx: DetailKeyContext | undefined): number {
852
+ const content = detailCtx?.contentLines ?? 0;
853
+ const viewH = detailCtx?.viewportHeight ?? 1;
854
+ return Math.max(0, content - viewH);
855
+ }
856
+
857
+ /** 处理取消按键(x)。background 真正 abort;sync 仅提示。返回是否变化。 */
858
+ function handleCancel(
859
+ record: SubagentRecord,
860
+ service: SubagentService | null,
861
+ state: ViewState,
862
+ notify: NotifyFn | undefined,
863
+ ): boolean {
864
+ if (record.status !== "running") {
865
+ notify?.(`Cannot stop: record is ${record.status}`, "warning");
866
+ return false;
867
+ }
868
+ if (record.mode === "background") {
869
+ if (!service) {
870
+ notify?.("Runtime not ready, cannot stop", "error");
871
+ return false;
872
+ }
873
+ const ok = service.cancel(record.id);
874
+ notify?.(ok ? `Requested stop for ${record.id}` : `Stop failed (record may have ended)`, ok ? "info" : "warning");
875
+ return true;
876
+ }
877
+ // sync:runtime 无法主动 abort(signal 来自 Pi tool 框架),仅提示
878
+ state.syncCancelHint = true;
879
+ notify?.("Press Esc in the chat to abort a sync subagent", "info");
880
+ return true;
881
+ }
882
+
883
+ // firstLine 已上移到 ./format.ts 共享。