@zhushanwen/pi-subagent-workflow 9.0.1 → 9.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 (52) hide show
  1. package/package.json +10 -10
  2. package/skills/subagent-ext-config/SKILL.md +9 -11
  3. package/src/host/__tests__/pi-host.test.ts +0 -1
  4. package/src/index.ts +7 -7
  5. package/src/injectors/subagent-list-injector.ts +1 -1
  6. package/src/interface/command/__tests__/commands-open.test.ts +278 -0
  7. package/src/interface/{commands.ts → command/commands.ts} +15 -7
  8. package/src/interface/{subagents.ts → command/subagents.ts} +4 -4
  9. package/src/interface/format/__tests__/display-state.test.ts +146 -0
  10. package/src/interface/format/display-state.ts +112 -0
  11. package/src/interface/{format.ts → format/format.ts} +20 -14
  12. package/src/interface/{id-preview.ts → format/id-preview.ts} +1 -1
  13. package/src/interface/{bg-notify-render.ts → gui/bg-notify-render.ts} +60 -35
  14. package/src/interface/gui/gui-mappers.ts +74 -0
  15. package/src/interface/{subagent-actions.ts → gui/subagent-actions.ts} +3 -3
  16. package/src/interface/tool/__tests__/subagent-tool-actions.test.ts +238 -0
  17. package/src/interface/{__tests__ → tool/__tests__}/subagent-tool-path-guard.test.ts +6 -5
  18. package/src/interface/{__tests__ → tool/__tests__}/tool-subagents.test.ts +4 -4
  19. package/src/interface/tool/__tests__/tool-workflow-resume.test.ts +161 -0
  20. package/src/interface/{__tests__ → tool/__tests__}/tool-workflow.test.ts +33 -9
  21. package/src/interface/{subagent-tool-schema.ts → tool/subagent-tool-schema.ts} +1 -1
  22. package/src/interface/{subagent-tool.ts → tool/subagent-tool.ts} +4 -4
  23. package/src/interface/{tool-render.ts → tool/tool-render.ts} +3 -3
  24. package/src/interface/{tool-shared.ts → tool/tool-shared.ts} +1 -1
  25. package/src/interface/{tool-subagents.ts → tool/tool-subagents.ts} +12 -11
  26. package/src/interface/{tool-workflow-script.ts → tool/tool-workflow-script.ts} +1 -1
  27. package/src/interface/{tool-workflow.ts → tool/tool-workflow.ts} +20 -165
  28. package/src/interface/tui/__tests__/list-view.test.ts +188 -0
  29. package/src/interface/{list-component.ts → tui/list-component.ts} +2 -2
  30. package/src/interface/{list-shared.ts → tui/list-shared.ts} +1 -1
  31. package/src/interface/{list-view.ts → tui/list-view.ts} +2 -2
  32. package/src/interface/{tui-kit.ts → tui/tui-kit.ts} +1 -1
  33. package/src/interface/{views → tui/views}/WorkflowsView.ts +10 -6
  34. package/src/interface/tui/views/__tests__/WorkflowsView.driver.test.ts +595 -0
  35. package/src/interface/{views → tui/views}/__tests__/WorkflowsView.test.ts +10 -3
  36. package/src/interface/tui/views/__tests__/detail-content.test.ts +195 -0
  37. package/src/interface/{views → tui/views}/detail-content.ts +8 -3
  38. package/src/interface/{views → tui/views}/view-constants.ts +2 -2
  39. package/src/jsonl-run-store.ts +133 -81
  40. package/src/session-lifecycle.ts +2 -1
  41. package/src/workflow-events.ts +21 -3
  42. package/src/workflow-notify.ts +7 -6
  43. package/src/interface/__tests__/tool-workflow-resume.test.ts +0 -227
  44. package/src/interface/gui-mappers.ts +0 -81
  45. /package/src/interface/{__tests__ → command/__tests__}/commands-resume.test.ts +0 -0
  46. /package/src/interface/{command-actions.ts → command/command-actions.ts} +0 -0
  47. /package/src/interface/{__tests__ → tool/__tests__}/capture-tool.ts +0 -0
  48. /package/src/interface/{__tests__ → tool/__tests__}/tool-prompt-contract.test.ts +0 -0
  49. /package/src/interface/{__tests__ → tool/__tests__}/tool-render.test.ts +0 -0
  50. /package/src/interface/{__tests__ → tool/__tests__}/tool-workflow-script.test.ts +0 -0
  51. /package/src/interface/{reentry-guard.ts → tool/reentry-guard.ts} +0 -0
  52. /package/src/interface/{tool-result.ts → tool/tool-result.ts} +0 -0
@@ -2,19 +2,20 @@
2
2
  * Subagent Workflow Extension — subagents tool(批量派发入口,一跳扁平 schema)。
3
3
  *
4
4
  * ⚠️ 命名同名不同命名空间,互指防混淆:本文件是 **tool**(模型调用的批量派发面,
5
- * 名为 `subagents`),`interface/subagents.ts` 是 **slash 命令**壳(`/subagents`:
5
+ * 名为 `subagents`),`interface/command/subagents.ts` 是 **slash 命令**壳(`/subagents`:
6
6
  * TUI list overlay + GUI 定向消息通道)。两者无共享状态、无调用关系——改本文件
7
7
  * 不影响命令壳,反之亦然。
8
8
  *
9
9
  * 行为契约(设计 §3.1 终态 / §3.3 D1-D3、D8、D9):
10
- * - 唯一批量入口:N 个已知独立任务一次派发;handler 确定性转译
11
- * runWorkflow("fan-out")(执行管道唯一——collect 时代的批协调状态已退役,
12
- * 不存在第二套批机制)。
13
- * - 批量派发的等价性契约:tasks[] 经 handler 确定性转译为内置 fan-out 模板脚本,
14
- * 走 runWorkflow 完整管道执行——模板仅由 tasks[] 确定性生成、不含任何批协调
15
- * 状态,执行管道唯一(不存在第二套批机制);走管道使批量派发继承 workflow 的
16
- * 持久化、journal、GUI 投影与断点恢复(resume)全套能力,这是刻意保留间接层
17
- * 的原因。
10
+ * - 唯一批量入口:N 个已知独立任务一次派发;handler 按固定名解析内置 `fan-out`
11
+ * 模板(FAN_OUT_SCRIPT_NAME,不经 registry 之外的第二套批机制),组装
12
+ * `args = {tasks, agents?, aggregate?}` 后交 runWorkflow 执行——**不生成脚本
13
+ * 文本**,执行形态 = 内置模板 + 一跳扁平 args 透传。
14
+ * - 执行形态保留间接层的原因:模板经 worker 执行模型(脚本 eval + postMessage
15
+ * 调用协议)运载,该形态同时承载 resume 重放(脚本确定性重跑 + 已 settled 调用
16
+ * 走 record 回放)、注入安全(workerData.scriptPath 定位 _shared,不回退当前目录)
17
+ * 与脚本可探索性(@pi-meta 进可用 workflow 清单)——走 workflow 管道使批量派发
18
+ * 继承持久化、journal、GUI 投影与断点恢复全套能力。
18
19
  * - 无 action 分发:status/abort 不复制,直接指路 workflow tool(runId 同体系)。
19
20
  * - 无 args 嵌套:tasks/agents/aggregate/... 全在顶层(弱模型信任 schema 结构信号,
20
21
  * 两跳转译是事故高发区——对照 workflow tool 的 name+args 形态)。
@@ -61,7 +62,7 @@ import type { RunStartDetails, WorkflowToolResult } from "./tool-result.ts";
61
62
  /**
62
63
  * 本工具的固定执行体(内置模板)脚本名。
63
64
  *
64
- * 常量而非参数:subagents tool 的契约就是「转译到 fan-out 模板」——参数化会让
65
+ * 常量而非参数:subagents tool 的契约就是「把 tasks 透传给 fan-out 模板」——参数化会让
65
66
  * 工具的语义随脚本漂移(对照 D3「执行管道唯一」)。脚本按名经 registry 解析
66
67
  * (内置名优先链与 workflow tool actionRun 同一条,见 plan U2「复刻既有加载路径」)。
67
68
  */
@@ -209,7 +210,7 @@ export async function runSubagentsBatch(
209
210
  );
210
211
  }
211
212
 
212
- // D3 确定性转译:tasks/agents/aggregate 原样进 args(模板参数面,$ARGS);未提供的
213
+ // D3 确定性 args 组装:tasks/agents/aggregate 原样进 args(模板参数面,$ARGS);未提供的
213
214
  // 缺省键不写入(模板侧 aggregate 缺省 false;空值不制造「都传/都缺」歧义形态)。
214
215
  const args: Record<string, unknown> = { tasks };
215
216
  if (params.agents !== undefined) args.agents = params.agents;
@@ -39,7 +39,7 @@ import {
39
39
  saveWorkflow,
40
40
  } from "@zhushanwen/subagent-core";
41
41
  import type { WorkflowScriptRegistry } from "@zhushanwen/subagent-core";
42
- import { toGuiCtx } from "./gui-mappers.ts";
42
+ import { toGuiCtx } from "../gui/gui-mappers.ts";
43
43
  import type { WorkflowToolResult } from "./tool-result.ts";
44
44
  import { assertNotAborted, renderTextResult, throwPrefixed, withGuiAttach } from "./tool-shared.ts";
45
45
 
@@ -17,7 +17,7 @@
17
17
  * 层归属:Interface。依赖 Pi SDK + Engine lifecycle/launcher + helpers。
18
18
  */
19
19
 
20
- import { readFileSync } from "node:fs";
20
+ import { dirname } from "node:path";
21
21
  import os from "node:os";
22
22
 
23
23
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -50,7 +50,7 @@ import {
50
50
  } from "@zhushanwen/subagent-core";
51
51
  import { assertEntryTimeBudget, assertEntryTokenBudget, assertSlugWithinLimit } from "@zhushanwen/subagent-core";
52
52
  import { runSummary } from "@zhushanwen/subagent-core";
53
- import { ID_PREVIEW_LENGTH } from "./id-preview.ts";
53
+ import { ID_PREVIEW_LENGTH } from "../format/id-preview.ts";
54
54
  import type { RunStartDetails, WorkflowToolResult } from "./tool-result.ts";
55
55
  import {
56
56
  acquireReentryGuard,
@@ -58,7 +58,7 @@ import {
58
58
  type ReentryGuardRef,
59
59
  releaseReentryGuard,
60
60
  } from "./reentry-guard.ts";
61
- import { formatRunStatusElapsed } from "./format.ts";
61
+ import { formatRunStatusElapsed } from "../format/format.ts";
62
62
  import {
63
63
  assertNotAborted,
64
64
  buildRunSpecFromScript,
@@ -107,7 +107,7 @@ const WorkflowParams = Type.Object({
107
107
  "the differing fields listed. Omit to reuse the original args.",
108
108
  }),
109
109
  ),
110
- tokens: Type.Optional(Type.Number({ description: "Max token budget — ONLY set when user explicitly requests a limit; omit = unlimited (default)" })),
110
+ tokens: Type.Optional(Type.Number({ description: "Max token budget — ONLY set when user explicitly requests a limit; omit = unlimited (default). For resume: the token budget applied to the resumed execution (tokens already spent by the run are counted against it)" })),
111
111
  time: Type.Optional(Type.Number({ description: `Max time budget in ms — ONLY set when user explicitly requests a limit; omit = unlimited (default; hard ceiling ${MAX_TIMER_DELAY_MS} ms — larger values fail fast at entry). For resume: the budget applied to the resumed execution (active time already spent by the run is counted against it; suspended time is not)` })),
112
112
  error: Type.Optional(
113
113
  Type.String({ description: "Error/reason message (optional, used with abort)" }),
@@ -236,7 +236,7 @@ export function registerWorkflowTool(
236
236
  "- run: {\"action\":\"run\",\"name\":\"<script>\",\"args\":{...},\"tokens\":N,\"time\":N,\"model\":\"<provider/modelId>\",\"thinkingLevel\":\"<level>\"}. " +
237
237
  "- status: {\"action\":\"status\"}. " +
238
238
  "- abort: {\"action\":\"abort\",\"runId\":\"<id>\"} (optional: {\"error\":\"<reason>\"}). " +
239
- "- resume: {\"action\":\"resume\",\"runId\":\"<id>\",\"args\":{...},\"time\":N} — args/time optional.",
239
+ "- resume: {\"action\":\"resume\",\"runId\":\"<id>\",\"args\":{...},\"tokens\":N,\"time\":N} — args/tokens/time optional.",
240
240
  "Budget: Do NOT set tokens/time unless the user explicitly requests a limit. Built-in workflows run unlimited by default.",
241
241
  "Model/thinkingLevel: omit by default (inherit main agent's model). Only set model/thinkingLevel when the user explicitly requests a specific model or thinking depth for this run.",
242
242
  "Anti-patterns: Flattening args sub-fields (task/items/...) to the top level — they belong inside args. Calling {\"action\":\"run\"} without name.",
@@ -527,140 +527,11 @@ async function actionAbort(
527
527
  }
528
528
  }
529
529
 
530
- // ── resume action(D14 args 校验 + resumeRun 接线)──────────
531
-
532
- /**
533
- * D14 比对排除键:rfl 仪表向 spec.args 原地注入的稳定 `_runId`(lifecycle
534
- * runWorkflow 注入面)——run 派发的机器字段,不属用户意图,比对前双侧剔除。
535
- */
536
- const RESUME_ARGS_EXCLUDED_KEY = "_runId";
537
-
538
- /** record 流内 run-created 帧的 args 读取结果(D14 比对的数据源形态)。 */
539
- type HistoricalArgs =
540
- | { kind: "args"; args: Record<string, unknown> }
541
- | { kind: "absent" }
542
- | { kind: "truncated" };
543
-
544
- /**
545
- * record 行 → run-created 帧窄化(type 字面量判别 + args 全文 / argsSummary 透传;
546
- * 非该帧 / 非 object 返回 undefined——结构判别走运行时守卫,不做断言)。
547
- */
548
- function asRunCreatedFrame(parsed: unknown): { args?: unknown; argsSummary: unknown } | undefined {
549
- if (typeof parsed !== "object" || parsed === null) return undefined;
550
- if (!("type" in parsed) || parsed.type !== "run-created") return undefined;
551
- return {
552
- args: "args" in parsed ? parsed.args : undefined,
553
- argsSummary: "argsSummary" in parsed ? parsed.argsSummary : undefined,
554
- };
555
- }
556
-
557
- /**
558
- * 读 run-created 帧的历史 args(D14 比对数据源;record 流是唯一事实源)。
559
- *
560
- * - args 全文优先(设计 §3.1 载荷表 run-created 行「args」——现行写入面
561
- * dispatchRunCreated 随帧落全文,任意体积的 args 都可逐字段深度比对);
562
- * - 流不存在 / 无 run-created 帧 → absent:不在 D14 层拒绝——资格判据(无 record
563
- * 流 / 首帧缺失)归 resumeRun 权威文案,此处不重复实现(分层:D14 只管 args 一致性);
564
- * - 旧格式帧回落 argsSummary(无 args 字段):截断(>256 字符,写侧
565
- * summarizeRunArgs 截断标记)→ truncated——截断摘要无法逐字段比对,保守拒绝
566
- * (静默放行 = 静默忽略传入 args,D14 不采用形态);
567
- * - 解析失败 → throw:args/argsSummary 是 JSON.stringify 产物,未截断必可解析——
568
- * 不可解析 = 流被篡改或写入器 bug(对齐 D12 拒绝精神)。
569
- */
570
- function readHistoricalArgs(recordPath: string, runId: string): HistoricalArgs {
571
- let content: string;
572
- try {
573
- content = readFileSync(recordPath, "utf8");
574
- } catch {
575
- return { kind: "absent" };
576
- }
577
- for (const line of content.split("\n")) {
578
- if (line.trim().length === 0) continue;
579
- let parsed: unknown;
580
- try {
581
- parsed = JSON.parse(line);
582
- } catch {
583
- continue;
584
- }
585
- const frame = asRunCreatedFrame(parsed);
586
- if (frame === undefined) continue;
587
- // args 全文优先:大 args run 的 D14 逐字段比对由此成立(truncated 拒绝只对
588
- // 旧格式帧的截断摘要形态出现)
589
- if (isPlainObject(frame.args)) return { kind: "args", args: frame.args };
590
- const summary = frame.argsSummary;
591
- if (typeof summary !== "string" || summary.length === 0) return { kind: "absent" };
592
- if (summary.endsWith("…")) return { kind: "truncated" };
593
- let args: unknown;
594
- try {
595
- args = JSON.parse(summary);
596
- } catch {
597
- // 未截断摘要必可解析(JSON.stringify 产物)——不可解析 = 字段被篡改/流损坏,
598
- // 与上方 malformed 分支同构拒绝(裸 SyntaxError 无恢复指引,不透出)。
599
- throw new Error(
600
- `Resume rejected: original args of run ${runId} are malformed in its record stream — ` +
601
- "the record stream is the sole source of truth. Recovery: inspect the record file for external edits; " +
602
- "if unrepairable, start a new run.",
603
- );
604
- }
605
- if (!isPlainObject(args)) {
606
- throw new Error(
607
- `Resume rejected: original args of run ${runId} are malformed in its record stream — ` +
608
- "the record stream is the sole source of truth. Recovery: inspect the record file for external edits; " +
609
- "if unrepairable, start a new run.",
610
- );
611
- }
612
- return { kind: "args", args };
613
- }
614
- return { kind: "absent" };
615
- }
616
-
617
- /** plain object 判定(数组/null 排除——数组按值比、不递归键差)。 */
618
- function isPlainObject(v: unknown): v is Record<string, unknown> {
619
- return typeof v === "object" && v !== null && !Array.isArray(v);
620
- }
621
-
622
- /**
623
- * 逐字段深度比对(D14)——返回差异字段描述列表(空 = 一致)。
624
- *
625
- * 差异三形态:仅 resume 侧有(unexpected)/ 仅原 run 侧有(missing)/ 值不等
626
- * (mismatch)。嵌套 plain object 递归比对(路径 `a.b` 形态);数组与原始值按
627
- * JSON 值比。值展示用 JSON.stringify(函数/undefined 等不可序列化形态按原样)。
628
- */
629
- export function diffResumeArgs(
630
- incoming: Record<string, unknown>,
631
- historical: Record<string, unknown>,
632
- ): string[] {
633
- return diffPlainObject(incoming, historical).map((d) => `args.${d}`);
634
- }
635
-
636
- /** diffResumeArgs 的递归体(裸键路径,`a.b` 形态——前缀由公开包装统一添加)。 */
637
- function diffPlainObject(
638
- incoming: Record<string, unknown>,
639
- historical: Record<string, unknown>,
640
- ): string[] {
641
- const diffs: string[] = [];
642
- const keys = new Set([...Object.keys(incoming), ...Object.keys(historical)]);
643
- keys.delete(RESUME_ARGS_EXCLUDED_KEY);
644
- for (const key of [...keys].sort()) {
645
- const hasIn = Object.hasOwn(incoming, key);
646
- const hasHist = Object.hasOwn(historical, key);
647
- if (!hasIn || !hasHist) {
648
- const holder = hasIn ? "resume args only (not in original run)" : "original run only (missing from resume args)";
649
- diffs.push(`${key}: ${holder}`);
650
- continue;
651
- }
652
- const a = incoming[key];
653
- const b = historical[key];
654
- if (isPlainObject(a) && isPlainObject(b)) {
655
- diffs.push(...diffPlainObject(a, b).map((d) => `${key}.${d}`));
656
- continue;
657
- }
658
- const sa = JSON.stringify(a);
659
- const sb = JSON.stringify(b);
660
- if (sa !== sb) diffs.push(`${key}: resume ${sa ?? String(a)} vs original ${sb ?? String(b)}`);
661
- }
662
- return diffs;
663
- }
530
+ // ── resume action(args 判定归 core D14 单源 + resumeRun 接线)──
531
+ //
532
+ // [§2.5 下沉] args 一致性判定(D14)已移入 core `orchestration/resume-args-guard.ts`,
533
+ // 由 `assertResumeEligibility` 用**已读到的** run-created 事件执行——本文件只做参数
534
+ // 装配(args + journalDir)与成功文案,不再读 record 文件、不再持有比对逻辑与拒绝文案。
664
535
 
665
536
  /**
666
537
  * resume action:interrupted 态 run 断点续跑。
@@ -671,6 +542,8 @@ function diffPlainObject(
671
542
  * 不一致明确拒绝并列出差异字段(换 args 重放 = 换意图,属新 run);不传默认沿用历史;
672
543
  * - time 可选——resume 执行段的时间预算(活跃段算式在 core:已耗活跃时间计入、
673
544
  * 搁置不计);入口护栏(负值/超安全域)与 run action 同源(assertEntryTimeBudget)。
545
+ * - tokens 可选——resume 执行段的 token 预算(已耗加权 tokens 计入,帧推导下界
546
+ * 口径);入口护栏(负值)与 run action 同源(assertEntryTokenBudget)。
674
547
  *
675
548
  * 资格拒绝(非 interrupted / record 损坏 / 锁被占 / D13 嵌套 / 预算耗尽)由 core
676
549
  * resumeRun 权威裁决——ResumeRejectionError 文案含恢复指引,原样透出(throw,
@@ -687,35 +560,17 @@ export async function actionResume(
687
560
  );
688
561
  }
689
562
  assertEntryTimeBudget(params.time);
690
-
691
- // D14 args 校验(fail-fast——先于任何副作用;不一致即拒绝,不触碰 run 状态)
692
- if (params.args !== undefined) {
693
- const historical = readHistoricalArgs(deps.store.stateFilePath(runId), runId);
694
- if (historical.kind === "truncated") {
695
- // 仅旧格式帧(args 全文载荷落地前落盘、截断摘要形态)到达此分支——现行
696
- // 写入面随帧落 args 全文,任意体积可逐字段比对
697
- throw new Error(
698
- `Resume rejected: original args of run ${runId} exceed the record's args summary limit ` +
699
- "(legacy record stream without the full-args payload) — they cannot be verified field-by-field. " +
700
- "Recovery: start a new run for these arguments, or resume WITHOUT args knowing $ARGS will be empty " +
701
- "(a truncated summary cannot be reconstructed).",
702
- );
703
- }
704
- if (historical.kind === "args") {
705
- const diffs = diffResumeArgs(params.args, historical.args);
706
- if (diffs.length > 0) {
707
- throw new Error(
708
- `Resume rejected: args for run ${runId} differ from the original run — resume replays the same intent; ` +
709
- `changed arguments belong to a new run. Differing fields:\n - ${diffs.join("\n - ")}\n` +
710
- "Recovery: pass the original args exactly, omit args to reuse them, or start a new run.",
711
- );
712
- }
713
- }
714
- // absent:D14 层不拒绝——无 record 流 / 无 run-created 帧的资格判据归 resumeRun 权威文案
715
- }
563
+ assertEntryTokenBudget(params.tokens);
716
564
 
717
565
  const options: ResumeRunOptions = {
718
566
  ...(params.time !== undefined ? { budgetTimeMs: params.time } : {}),
567
+ // token 预算显式覆盖(与 time 同款通道;三档回落与落盘归 core 单点)
568
+ ...(params.tokens !== undefined ? { budgetTokens: params.tokens } : {}),
569
+ // [§2.5] args 原样下传由 core 判定(D14 单源);journalDir 传壳的 store 同源目录,
570
+ // 否则 core 会按模块锚解析 record 路径——多 session 场景会静默读成「无记录」
571
+ //(D14 静默放行 = 安全语义反转;core 锚点与壳 store 锚点必须同源)。
572
+ ...(params.args !== undefined ? { args: params.args } : {}),
573
+ journalDir: dirname(deps.store.stateFilePath(runId)),
719
574
  host: os.hostname(),
720
575
  };
721
576
  await resumeRun(runId, deps, options);
@@ -0,0 +1,188 @@
1
+ /**
2
+ * createSubagentsView factory 驱动测试(与 list-component.test.ts 的组件/键位直测互补:
3
+ * 本文件只覆盖 factory 编排面——G-017 防叠加、directId 分流、onChange 防抖订阅、
4
+ * animTimer 动画循环、wrappedDone 收尾顺序)。
5
+ *
6
+ * 驱动方式:fake ctx.ui.custom 同步调 factory 捕获 Component;service 为最小 stub
7
+ * (collectRecords / onChange / cancel);fake timers 驱动 250ms 动画帧与 120ms 防抖。
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { SubagentRecord, SubagentService } from "@zhushanwen/subagent-core";
13
+
14
+ import type { ThemeLike } from "../../format/format.ts";
15
+ import { createSubagentsView } from "../list-view.ts";
16
+
17
+ const ESC = "\x1b";
18
+
19
+ const plainTheme: ThemeLike = {
20
+ bg: (_color: string, text: string) => text,
21
+ fg: (_tag: string, text: string) => text,
22
+ bold: (text: string) => text,
23
+ underline: (text: string) => text,
24
+ };
25
+
26
+ function makeRecord(over: Partial<SubagentRecord> = {}): SubagentRecord {
27
+ return {
28
+ id: "run-1",
29
+ agent: "worker",
30
+ task: "do the thing",
31
+ status: "closed",
32
+ mode: "sync",
33
+ startedAt: 1000,
34
+ endedAt: 2000,
35
+ depth: 0,
36
+ turns: 1,
37
+ totalTokens: 10,
38
+ model: "test/model",
39
+ eventLog: [],
40
+ displayItems: [],
41
+ result: "ok",
42
+ ...over,
43
+ } as SubagentRecord;
44
+ }
45
+
46
+ interface ListComponentView { // oe-exempt:20260930:test:test double shape for component capture (test infra)
47
+ render(width: number): string[];
48
+ handleInput(data: string): void;
49
+ invalidate(): void;
50
+ hasRunning(): boolean;
51
+ }
52
+
53
+ interface Driver { // oe-exempt:20260930:test:test fixture harness shape (test infra)
54
+ component: ListComponentView;
55
+ done: Promise<void>;
56
+ notifies: Array<{ msg: string; type?: string }>;
57
+ service: {
58
+ collectRecords: ReturnType<typeof vi.fn>;
59
+ onChange: ReturnType<typeof vi.fn>;
60
+ cancel: ReturnType<typeof vi.fn>;
61
+ };
62
+ unsubscribe: ReturnType<typeof vi.fn>;
63
+ tui: { terminal: { columns: number; rows: number }; requestRender: ReturnType<typeof vi.fn> };
64
+ }
65
+
66
+ function openDriver(opts: { records?: SubagentRecord[]; directId?: string } = {}): Driver {
67
+ const records = opts.records ?? [makeRecord()];
68
+ const unsubscribe = vi.fn();
69
+ const collectRecords = vi.fn(() => records);
70
+ const onChange = vi.fn(() => unsubscribe);
71
+ const cancel = vi.fn(() => true);
72
+ const getFullRecord = vi.fn(() => undefined as SubagentRecord | undefined);
73
+ const service = {
74
+ queries: { collectRecords, onChange, getFullRecord, findRecord: vi.fn(() => undefined), lookupRecordAnyState: vi.fn(() => undefined) },
75
+ cancel,
76
+ } as unknown as SubagentService;
77
+ const tui = { terminal: { columns: 100, rows: 30 }, requestRender: vi.fn() };
78
+ const notifies: Array<{ msg: string; type?: string }> = [];
79
+ let component!: ListComponentView;
80
+ let resolveDone!: () => void;
81
+ const done = new Promise<void>((resolve) => {
82
+ resolveDone = resolve;
83
+ });
84
+ const ctx = {
85
+ mode: "tui",
86
+ ui: {
87
+ notify: (msg: string, type?: string) => notifies.push({ msg, type }),
88
+ custom: (
89
+ factory: (t: unknown, th: unknown, kb: unknown, d: (v: void) => void) => ListComponentView,
90
+ ): Promise<void> => {
91
+ component = factory(tui, plainTheme, {}, () => resolveDone());
92
+ return done;
93
+ },
94
+ },
95
+ } as unknown as ExtensionContext;
96
+ void createSubagentsView(service, plainTheme, ctx, opts.directId);
97
+ return { component, done, notifies, service: { collectRecords, onChange, cancel }, unsubscribe, tui };
98
+ }
99
+
100
+ beforeEach(() => {
101
+ vi.useFakeTimers();
102
+ });
103
+
104
+ afterEach(() => {
105
+ vi.useRealTimers();
106
+ });
107
+
108
+ describe("createSubagentsView factory", () => {
109
+ it("打开即返回可用组件:render 出行、无 directId 时零 notify", () => {
110
+ const d = openDriver();
111
+ expect(d.component.render(100).length).toBeGreaterThan(0);
112
+ expect(d.notifies).toEqual([]);
113
+ expect(d.service.onChange).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it("directId 未命中 → warning notify,仍打开列表", () => {
117
+ const d = openDriver({ directId: "ghost" });
118
+ expect(d.notifies).toEqual([{ msg: 'No record found for id "ghost", showing all', type: "warning" }]);
119
+ expect(d.component.render(100).length).toBeGreaterThan(0);
120
+ });
121
+
122
+ it("directId 命中 → 直接进详情模式(esc 先退详情而非关闭 overlay)", async () => {
123
+ const d = openDriver({ directId: "run-1" });
124
+ d.component.handleInput(ESC); // detailMode=true → 退出详情,overlay 保持
125
+ const resolved = await Promise.race([d.done.then(() => true), Promise.resolve(false)]);
126
+ expect(resolved).toBe(false);
127
+ expect(d.unsubscribe).not.toHaveBeenCalled();
128
+ // 再按 esc(list 模式无 filter)→ 关闭
129
+ d.component.handleInput(ESC);
130
+ await d.done;
131
+ expect(d.unsubscribe).toHaveBeenCalledTimes(1);
132
+ });
133
+
134
+ it("G-017 防叠加:第二个 overlay 打开时关闭前一个(前一 done 落定 + 解订)", async () => {
135
+ const d1 = openDriver();
136
+ const d2 = openDriver();
137
+ await d1.done; // 前一个被 activeView.close() 关闭
138
+ expect(d1.unsubscribe).toHaveBeenCalledTimes(1);
139
+ // d2 仍存活:esc 关闭
140
+ d2.component.handleInput(ESC);
141
+ await d2.done;
142
+ expect(d2.unsubscribe).toHaveBeenCalledTimes(1);
143
+ });
144
+
145
+ it("onChange 防抖:120ms 窗口内多次触发只重绘一次;disposed 后触发早退", async () => {
146
+ const d = openDriver();
147
+ const fire = d.service.onChange.mock.calls[0][0] as () => void;
148
+ fire();
149
+ fire();
150
+ fire();
151
+ vi.advanceTimersByTime(120);
152
+ expect(d.tui.requestRender).toHaveBeenCalledTimes(1);
153
+ // 关闭后触发 → disposed 早退,不再重绘
154
+ d.component.handleInput(ESC);
155
+ await d.done;
156
+ const calls = d.tui.requestRender.mock.calls.length;
157
+ fire();
158
+ vi.advanceTimersByTime(120);
159
+ expect(d.tui.requestRender.mock.calls.length).toBe(calls);
160
+ });
161
+
162
+ it("animTimer:有 running record 时 250ms 帧刷新;全终态后停止浪费刷新", () => {
163
+ const d = openDriver({ records: [makeRecord({ status: "running" })] });
164
+ expect(d.component.hasRunning()).toBe(true);
165
+ vi.advanceTimersByTime(250);
166
+ expect(d.tui.requestRender).toHaveBeenCalledTimes(1);
167
+ // 记录集变全终态(collectRecords 每次重查)→ hasRunning false → 不再 invalidate
168
+ (d.service.collectRecords as ReturnType<typeof vi.fn>).mockReturnValue([makeRecord({ status: "closed" })]);
169
+ vi.advanceTimersByTime(250);
170
+ expect(d.tui.requestRender).toHaveBeenCalledTimes(1);
171
+ });
172
+
173
+ it("wrappedDone 幂等:esc 关闭后重复关闭不再解订/不再 done", async () => {
174
+ const d = openDriver();
175
+ d.component.handleInput(ESC);
176
+ await d.done;
177
+ // 组件 closeFn 幂等(state.disposed 早退),二次 esc 无副作用
178
+ d.component.handleInput(ESC);
179
+ expect(d.unsubscribe).toHaveBeenCalledTimes(1);
180
+ });
181
+
182
+ it("cancel 接线:详情模式对 running record 按 x → service.cancel + info notify", () => {
183
+ const d = openDriver({ records: [makeRecord({ id: "run-x", status: "running" })], directId: "run-x" });
184
+ d.component.handleInput("x");
185
+ expect(d.service.cancel).toHaveBeenCalledWith("run-x");
186
+ expect(d.notifies).toEqual([{ msg: "Requested stop for run-x", type: "info" }]);
187
+ });
188
+ });
@@ -1,4 +1,4 @@
1
- // src/interface/list-component.ts
1
+ // src/interface/tui/list-component.ts
2
2
  //
3
3
  // /subagents list 全屏带框左右分屏组件实现。
4
4
  // 从 list-view.ts 抽出(文件行数控制):list-view.ts 保留 factory / key 处理,
@@ -27,7 +27,7 @@ import {
27
27
  spinnerGlyph,
28
28
  statusGlyph,
29
29
  type ThemeLike,
30
- } from "./format.ts";
30
+ } from "../format/format.ts";
31
31
  import {
32
32
  b,
33
33
  dash,
@@ -1,4 +1,4 @@
1
- // src/interface/list-shared.ts
1
+ // src/interface/tui/list-shared.ts
2
2
  //
3
3
  // list-view 与 list-component 共享的类型/常量/纯函数,避免两者循环依赖。
4
4
  // 原循环:list-view import 组件类 SubagentsListComponent;组件 import key 处理 + 状态类型。
@@ -1,4 +1,4 @@
1
- // src/interface/list-view.ts
1
+ // src/interface/tui/list-view.ts
2
2
  //
3
3
  // /subagents list 全屏带框左右分屏 overlay。
4
4
  // 左列:record 列表(状态图标 + agent + mode + 绝对时长)
@@ -37,7 +37,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
37
37
 
38
38
  import type { SubagentService } from "@zhushanwen/subagent-core";
39
39
  import type { SubagentRecord } from "@zhushanwen/subagent-core";
40
- import { type ThemeLike } from "./format.ts";
40
+ import { type ThemeLike } from "../format/format.ts";
41
41
  import { SubagentsListComponent } from "./list-component.ts";
42
42
  import {
43
43
  type DetailKeyContext,
@@ -1,4 +1,4 @@
1
- // src/interface/tui-kit.ts
1
+ // src/interface/tui/tui-kit.ts
2
2
  //
3
3
  // TUI 终端零件 kit(post-convergence C4):全屏视图族的共享零件单点——
4
4
  // 终端探测常量 / termRows() / 边框着色家族(b/dash/dashes/titleBorder/plainBorder/walled,
@@ -36,6 +36,11 @@ import {
36
36
  import type { ExecutionTraceNode } from "@zhushanwen/subagent-core";
37
37
  import type { SubagentRecord } from "@zhushanwen/subagent-core";
38
38
  import type { WorkflowRun } from "@zhushanwen/subagent-core";
39
+ import {
40
+ formatRunBadge,
41
+ runDisplaySignaturePart,
42
+ runDisplayStateOf,
43
+ } from "../../format/display-state.ts";
39
44
  import {
40
45
  buildPhaseGroups,
41
46
  ELLIPSIS,
@@ -44,13 +49,12 @@ import {
44
49
  formatElapsed,
45
50
  formatElapsedSeconds,
46
51
  formatPhaseLine,
47
- formatStatusBadge,
48
52
  statusDotStr,
49
53
  type ThemeLike,
50
- } from "../format.ts";
54
+ } from "../../format/format.ts";
51
55
  // [W2/V1 D1 分流表第 9 行] CLI 视图显示换源:投影二值 status(混合判源收拢在
52
56
  // displayStatusOf 单点——判源 = core runSummary 投影)。
53
- import { displayStatusOf } from "../tool-workflow.ts";
57
+ import { displayStatusOf } from "../../tool/tool-workflow.ts";
54
58
  import {
55
59
  b,
56
60
  dashes,
@@ -233,7 +237,7 @@ export function computeRenderSignature(
233
237
  const l = liveProgress.get(n.stepIndex);
234
238
  return `${n.stepIndex}:${n.status}:${n.sessionFile ?? "-"}:${l?.totalTokens ?? -1}:${l?.toolCallCount ?? -1}:${l?.elapsedSeconds ?? -1}:${l?.turns ?? -1}:${l?.eventLog.length ?? -1}:${l?.currentActivity ? `${l.currentActivity.type}:${l.currentActivity.label}` : "-"}:${l?.lastError ?? "-"}`;
235
239
  });
236
- return [displayStatusOf(run), Math.floor(now / SECOND_MS), `${completed}/${traceArr.length}`, budgetPart, errorLogsPart, ...nodeParts].join("|");
240
+ return [runDisplaySignaturePart(runDisplayStateOf(run)), Math.floor(now / SECOND_MS), `${completed}/${traceArr.length}`, budgetPart, errorLogsPart, ...nodeParts].join("|");
237
241
  }
238
242
 
239
243
  // ── View state ────────────────────────────────────────────────
@@ -706,7 +710,7 @@ function renderHeader(
706
710
  const completed = traceArr.filter((n) => n.status === "completed").length;
707
711
  const total = traceArr.length;
708
712
  const elapsed = formatElapsed(run.meta.startedAt);
709
- const headerRight = `${formatStatusBadge(displayStatusOf(run), theme)} · ${completed}/${total} agents · ${elapsed}`;
713
+ const headerRight = `${formatRunBadge(runDisplayStateOf(run), theme)} · ${completed}/${total} agents · ${elapsed}`;
710
714
  const budget = run.state.budget;
711
715
  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)}`;
712
716
 
@@ -749,7 +753,7 @@ function renderFooter(
749
753
  ? "↑↓ agent · ⏎ detail"
750
754
  : "↑↓ agent · ⏎ prompt · PgUp/PgDn scroll";
751
755
  const actionParts: string[] = [];
752
- if (displayStatusOf(run) === "running") {
756
+ if (runDisplayStateOf(run).abortable) {
753
757
  actionParts.push("a abort");
754
758
  }
755
759
  actionParts.push("s save");