@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
@@ -0,0 +1,146 @@
1
+ // src/interface/__tests__/display-state.test.ts
2
+ //
3
+ // [§2.2 B 档] run 域展示态单点:三态投影 + 色调 / 徽标 / 签名片段三张映射的等价性。
4
+ //
5
+ // 回归价值:本批把原散在 WorkflowsView 三处的判定收敛到一处——本测试同时钉住
6
+ // 「与既有 TUI 输出逐字一致」与「run 域色档与 format.ts 的 statusColorToken 同档」
7
+ //(后者是本批修掉的三处可见错误之一的防线)。
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ import {
11
+ doneReasonToRunOutcome,
12
+ noteRebuiltSettlement,
13
+ type DoneReason,
14
+ type WorkflowRun,
15
+ } from "@zhushanwen/subagent-core";
16
+
17
+ import {
18
+ formatRunBadge,
19
+ isFailedTerminal,
20
+ runDisplaySignaturePart,
21
+ runDisplayStateOf,
22
+ runToneOf,
23
+ } from "../display-state.ts";
24
+ import {
25
+ formatStatusBadge,
26
+ type ThemeLike,
27
+ } from "../format.ts";
28
+
29
+ const markingTheme: ThemeLike = {
30
+ fg: (color, text) => `${color}(${text})`,
31
+ bg: (color, text) => `${color}(${text})`,
32
+ bold: (text) => `bold(${text})`,
33
+ underline: (text) => `u(${text})`,
34
+ };
35
+
36
+ /** 每次构造唯一 runId——终局记录注册表按 runId 分区,跨用例不复用条目。 */
37
+ let displayRunSeq = 0;
38
+
39
+ /** 最小 run 形态(display-state 读 runSummary:runId/spec/meta/state 四组字段)。 */
40
+ function makeRun(shape: {
41
+ status?: string;
42
+ reason?: string;
43
+ interruptedAt?: string;
44
+ budget?: { usedTokens: number; maxTokens?: number; usedCost: number };
45
+ } = {}): WorkflowRun {
46
+ const runId = `wf-display-${displayRunSeq++}`;
47
+ const status = shape.status ?? "running";
48
+ // [D6(a) 第 1 步] 终局判定源 = 终局记录注册表:done 形态 fixture 必须携带注册表
49
+ // 条目(生产 = 壳重建点 noteRebuiltSettlement 注入 run-settled 帧事实)——
50
+ // doneReason 经 outcome 往返(budget_limited 需 errorCode 同载)。
51
+ if (status === "done") {
52
+ const doneReason = (shape.reason ?? "completed") as DoneReason;
53
+ noteRebuiltSettlement(runId, {
54
+ outcome: doneReasonToRunOutcome(doneReason),
55
+ ...(doneReason === "budget_limited" ? { errorCode: "budget_limited" as const } : {}),
56
+ settledAt: Date.parse("2026-09-30T02:00:00.000Z"),
57
+ });
58
+ }
59
+ return {
60
+ runId,
61
+ spec: { scriptName: "display-wf" },
62
+ meta: { startedAt: "2026-09-30T00:00:00.000Z", ...(shape.interruptedAt ? { interruptedAt: shape.interruptedAt } : {}) },
63
+ state: {
64
+ status,
65
+ ...(shape.reason !== undefined ? { reason: shape.reason } : {}),
66
+ budget: shape.budget ?? { usedTokens: 0, maxTokens: 200_000, usedCost: 0 },
67
+ trace: { toArray: () => [] },
68
+ errorLogs: [],
69
+ },
70
+ } as unknown as WorkflowRun;
71
+ }
72
+
73
+ describe("runDisplayStateOf(三态投影与可中断性)", () => {
74
+ it("running → status running + abortable", () => {
75
+ const ds = runDisplayStateOf(makeRun());
76
+ expect(ds.status).toBe("running");
77
+ expect(ds.abortable).toBe(true);
78
+ expect(ds.doneReason).toBeUndefined();
79
+ });
80
+
81
+ it("interrupted(meta.interruptedAt)→ 不可中断、无 doneReason", () => {
82
+ const ds = runDisplayStateOf(makeRun({ interruptedAt: "2026-09-30T01:00:00.000Z" }));
83
+ expect(ds.status).toBe("interrupted");
84
+ expect(ds.abortable).toBe(false);
85
+ });
86
+
87
+ it("done + reason → 不可中断 + doneReason 透传", () => {
88
+ const ds = runDisplayStateOf(makeRun({ status: "done", reason: "failed" }));
89
+ expect(ds.status).toBe("done");
90
+ expect(ds.abortable).toBe(false);
91
+ expect(ds.doneReason).toBe("failed");
92
+ });
93
+
94
+ it("会计面透传活体快照(含 maxTokens 缺席形态)", () => {
95
+ const ds = runDisplayStateOf(makeRun({ budget: { usedTokens: 1234, usedCost: 0.5 } }));
96
+ expect(ds.accounting).toEqual({ usedTokens: 1234, usedCost: 0.5 });
97
+ });
98
+ });
99
+
100
+ describe("映射表(色调 / 徽标 / 失败族)", () => {
101
+ it.each([
102
+ [{}, "warning", "warning(● running)"],
103
+ [{ interruptedAt: "t" }, "muted", "muted(○ interrupted)"],
104
+ [{ status: "done", reason: "completed" }, "success", "success(✓ completed)"],
105
+ [{ status: "done" }, "success", "success(✓ completed)"],
106
+ [{ status: "done", reason: "failed" }, "error", "error(✗ failed)"],
107
+ [{ status: "done", reason: "aborted" }, "error", "error(✗ aborted)"],
108
+ [{ status: "done", reason: "budget_limited" }, "error", "error(⚠ budget)"],
109
+ [{ status: "done", reason: "time_limited" }, "error", "error(⚠ timeout)"],
110
+ ] as const)("形态 %j → tone %s / badge %s", (shape, tone, badge) => {
111
+ const ds = runDisplayStateOf(makeRun(shape as never));
112
+ expect(runToneOf(ds)).toBe(tone);
113
+ expect(formatRunBadge(ds, markingTheme)).toBe(badge);
114
+ });
115
+
116
+ it("失败族判定只认 done + 非 completed", () => {
117
+ expect(isFailedTerminal(runDisplayStateOf(makeRun({ status: "done", reason: "failed" })))).toBe(true);
118
+ expect(isFailedTerminal(runDisplayStateOf(makeRun({ status: "done", reason: "completed" })))).toBe(false);
119
+ expect(isFailedTerminal(runDisplayStateOf(makeRun({ status: "done" })))).toBe(false);
120
+ expect(isFailedTerminal(runDisplayStateOf(makeRun()))).toBe(false);
121
+ });
122
+
123
+ it("run 域徽标与 format.ts 的既有徽标逐字一致(同输入同输出,防两表漂移)", () => {
124
+ const cases: Array<[never, string]> = [
125
+ [{} as never, "running"],
126
+ [{ interruptedAt: "t" } as never, "interrupted"],
127
+ [{ status: "done", reason: "completed" } as never, "completed"],
128
+ [{ status: "done", reason: "failed" } as never, "failed"],
129
+ [{ status: "done", reason: "aborted" } as never, "aborted"],
130
+ [{ status: "done", reason: "budget_limited" } as never, "budget_limited"],
131
+ [{ status: "done", reason: "time_limited" } as never, "time_limited"],
132
+ ];
133
+ for (const [shape, legacyStatus] of cases) {
134
+ const ds = runDisplayStateOf(makeRun(shape));
135
+ expect(formatRunBadge(ds, markingTheme)).toBe(formatStatusBadge(legacyStatus as never, markingTheme));
136
+ }
137
+ });
138
+
139
+ it("签名片段:生命周期面全字段进签名(状态/原因/可中断性任一变化即失效)", () => {
140
+ const running = runDisplaySignaturePart(runDisplayStateOf(makeRun()));
141
+ const failed = runDisplaySignaturePart(runDisplayStateOf(makeRun({ status: "done", reason: "failed" })));
142
+ const completed = runDisplaySignaturePart(runDisplayStateOf(makeRun({ status: "done", reason: "completed" })));
143
+ expect(new Set([running, failed, completed]).size).toBe(3);
144
+ expect(running).toContain("running");
145
+ });
146
+ });
@@ -0,0 +1,112 @@
1
+ // src/interface/format/display-state.ts
2
+ //
3
+ // [§2.2 B 档 · run 域] 展示态单一中间表示——run 域所有展示映射的唯一输入。
4
+ //
5
+ // 为什么需要(登记 §2.2):run 生命周期的展示此前散在三处各写各的判定与映射
6
+ //(`WorkflowsView.renderHeader` 的徽标、`renderFooter` 的 abort 可用性、
7
+ // `computeRenderSignature` 的签名首段),每处自己决定「什么状态显示成什么」;改一处
8
+ // 漏一处即出现口径漂移(本批修掉的三处可见错误正是这种漂移的产物)。
9
+ //
10
+ // 边界(刻意不做的):**不并子代理域与通知域**。run 生命周期(三态投影)与子代理执行
11
+ // 状态(running/idle)、通知正文(outcome + closedReason 兜底)没有共同的输入词汇,
12
+ // 强行共用一张表会把两套语义塞进一个枚举——`gui-mappers.ts` 与 `bg-notify-render.ts`
13
+ // 各自留在自己的域里(§2.2 裁决)。
14
+ //
15
+ // 单一判定源纪律:本模块**不判定状态**——`status` 直接取 core `runSummary(run).status`
16
+ //(pi 侧唯一投影,`tool-workflow.displayStatusOf` 同源),只把「生命周期 / 终局原因 /
17
+ // 可中断性 / 会计」整理成展示形态。判定 1 个、展示 1 个。
18
+ import { runSummary, type DoneReason, type WorkflowRun } from "@zhushanwen/subagent-core";
19
+
20
+ import type { ThemeLike } from "./format.ts";
21
+
22
+ /** run 展示三态(与 core `runSummary` 投影、shared `WorkflowRunStatus` 同词)。 */
23
+ export type RunDisplayStatus = "running" | "interrupted" | "done";
24
+
25
+ /** run 展示态(唯一中间表示)。 */
26
+ export interface RunDisplayState { // oe-exempt:20260930:framework:run 域展示契约——TUI 各渲染位共用同一形状,非单实现投机抽象
27
+ /** 生命周期三态(唯一判定源 = core runSummary)。 */
28
+ status: RunDisplayStatus;
29
+ /** 终局原因(仅 status === "done" 且 core 侧有值时;展示文案与失败族判定用)。 */
30
+ doneReason?: DoneReason;
31
+ /** 可中断(abort)——仅 running;interrupted 态已停、done 已终局。 */
32
+ abortable: boolean;
33
+ /** 会计面(活体快照值;重启重建后的近似值同样走这里,见 §2.1b)。 */
34
+ accounting: {
35
+ usedTokens: number;
36
+ maxTokens?: number;
37
+ usedCost: number;
38
+ };
39
+ }
40
+
41
+ /**
42
+ * run → 展示态(纯函数,不读 store、不发事件)。
43
+ *
44
+ * 注意 `runSummary` 的终局判定优先读进程内终局注册表(活体终局),重启后由 record
45
+ * 流 fold 出的聚合兜底——因此本函数对活体与重水合两种形态同样正确。
46
+ */
47
+ export function runDisplayStateOf(run: WorkflowRun): RunDisplayState {
48
+ const summary = runSummary(run);
49
+ const budget = run.state.budget;
50
+ return {
51
+ status: summary.status,
52
+ ...(summary.reason !== undefined ? { doneReason: summary.reason } : {}),
53
+ abortable: summary.status === "running",
54
+ accounting: {
55
+ usedTokens: budget.usedTokens,
56
+ ...(budget.maxTokens !== undefined ? { maxTokens: budget.maxTokens } : {}),
57
+ usedCost: budget.usedCost,
58
+ },
59
+ };
60
+ }
61
+
62
+ /** 失败族终局(done 且原因非 completed)——色档与徽标共用同一判定,防两处各判。 */
63
+ export function isFailedTerminal(ds: RunDisplayState): boolean {
64
+ return ds.status === "done" && ds.doneReason !== undefined && ds.doneReason !== "completed";
65
+ }
66
+
67
+ /**
68
+ * 展示态 → 语义色调(run 域色档单表)。
69
+ *
70
+ * 与 `format.ts` 的 `statusColorToken`(trace 节点 / 通知域用)保持同档:running =
71
+ * warning、interrupted = muted、失败族 = error、成功终局 = success;两处**输入词汇不同**
72
+ *(本表吃 RunDisplayState,那张吃 StatusText),故各自成表但档位口径一致。
73
+ */
74
+ export function runToneOf(ds: RunDisplayState): "success" | "warning" | "error" | "muted" {
75
+ switch (ds.status) {
76
+ case "running": return "warning";
77
+ case "interrupted": return "muted";
78
+ case "done": return isFailedTerminal(ds) ? "error" : "success";
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 展示态 → 头部徽标(run 域唯一徽标构造点)。
84
+ *
85
+ * 文案与符号沿用既有 TUI 输出(逐字不变):● running / ○ interrupted / ✓ completed /
86
+ * ✗ failed / ✗ aborted / ⚠ budget / ⚠ timeout。done 缺 reason 时按 completed 显示
87
+ *(旧格式 run 的常见形态)。
88
+ */
89
+ export function formatRunBadge(ds: RunDisplayState, theme: ThemeLike): string {
90
+ if (ds.status === "running") return theme.fg("warning", "\u25CF running");
91
+ if (ds.status === "interrupted") return theme.fg("muted", "\u25CB interrupted");
92
+ switch (ds.doneReason) {
93
+ case "budget_limited": return theme.fg("error", "\u26A0 budget");
94
+ case "time_limited": return theme.fg("error", "\u26A0 timeout");
95
+ case "aborted": return theme.fg("error", "\u2717 aborted");
96
+ case "failed": return theme.fg("error", "\u2717 failed");
97
+ default: return theme.fg("success", "\u2713 completed");
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 展示态 → 渲染签名片段(`computeRenderSignature` 用)。
103
+ *
104
+ * 签名的作用是「字段变了必须失效重绘」——把展示态的**生命周期面全字段**序列化进来,
105
+ * 新增字段自动进签名,不再依赖「手工同步签名表」的纪律(原实现只放 status,新增展示
106
+ * 字段靠人记得补签名,DS8 维护约定即为此存在)。会计面(accounting)**刻意不重复进
107
+ * 本片段**:签名里已有的 `budgetPart` 是同一组值的既有同精度形态(token 取整桶 +
108
+ * cost 定点小数),重复只会引入第二套精度口径。
109
+ */
110
+ export function runDisplaySignaturePart(ds: RunDisplayState): string {
111
+ return [ds.status, ds.doneReason ?? "-", ds.abortable ? "A" : "-"].join(":");
112
+ }
@@ -1,4 +1,4 @@
1
- // src/interface/format.ts
1
+ // src/interface/format/format.ts
2
2
  //
3
3
  // 纯格式化函数.零 Pi 依赖、零 runtime 依赖,可单测.
4
4
  //
@@ -7,7 +7,7 @@
7
7
  // 禁用 `│` 做 stats 分隔、`├─`/`└─` 做 eventLog 前缀.
8
8
  //
9
9
  // 截断/填充/换行(truncLine / padToVisible / segFillColored / wrapText)单定义
10
- // ./tui-kit.ts(零依赖叶),消费方直接从 tui-kit import。
10
+ // ../tui/tui-kit.ts(零依赖叶),消费方直接从 tui-kit import。
11
11
 
12
12
  import os from "node:os";
13
13
 
@@ -21,7 +21,6 @@ import type {
21
21
  DoneReason,
22
22
  ExecutionTraceNode,
23
23
  RunEventFoldCheckpoint,
24
- RunStatus,
25
24
  ToolCallEntry,
26
25
  } from "@zhushanwen/subagent-core";
27
26
  import { displayAgentName } from "@zhushanwen/subagent-core";
@@ -414,15 +413,15 @@ export function formatDisplayItem(item: DisplayItem, theme: ThemeLike): string {
414
413
  // 自 views/format.ts 并入(D7-① 双轨合并):workflow 视图特有的 badge/phase/
415
414
  // trace 行格式化在此作差异段保留。并入时收敛的同构构件——ThemeLike、
416
415
  // formatElapsedSeconds(本文件版含小时分支,>1h 显示 "1h15m" 而非 "75m30s")、
417
- // segFillColored、padToVisible(后者已随 C4 迁 ./tui-kit.ts,消费方直接 import)。
416
+ // segFillColored、padToVisible(后者已随 C4 迁 ../tui/tui-kit.ts,消费方直接 import)。
418
417
  // ============================================================
419
418
 
420
419
  // ── Workflow view 布局常量 ────────────────────────────────────
421
420
 
422
421
  // views 专属布局常量(SIDEBAR_WIDTH / PROMPT_FOLD_LINES / OUTPUT_TRUNCATE_BYTES /
423
422
  // BOX_BORDER_CHARS / BUDGET_TOKENS_DIVISOR / MAX_TOOL_CALLS_DISPLAY)已沉回
424
- // ./views/view-constants.ts(就近消费方,post-convergence C4);
425
- // 终端兜底零件(终端行数兜底、翻页兜底步长)单定义在 ./tui-kit.ts。
423
+ // ../tui/views/view-constants.ts(就近消费方,post-convergence C4);
424
+ // 终端兜底零件(终端行数兜底、翻页兜底步长)单定义在 ../tui/tui-kit.ts。
426
425
  // ELLIPSIS 留此:formatActivityLine / formatPhaseLine(本文件)亦消费,非 views 专属。
427
426
  export const ELLIPSIS = "\u2026"; // U+2026
428
427
 
@@ -432,16 +431,18 @@ const MS_PER_SEC = 1000;
432
431
  /**
433
432
  * 可显示的状态文本集合。
434
433
  *
435
- * 包含 RunStatus("running"|"done" 不直接显示,转 reason)+ 中断投影态
436
- * "interrupted"([D2] 重水合中断 run 经 meta.interruptedAt 投影——displayStatusOf
437
- * 三态输出的第三值)+ DoneReason(completed/failed/aborted/budget_limited/
438
- * time_limited)+ ExecutionTraceNode.status(含 "pending"——trace 节点的初始态)。
434
+ * 生命周期三态("running"|"done" 不直接显示,done 转 reason;"interrupted" 为
435
+ * [D2] 重水合中断投影——displayStatusOf 三态输出的第三值,与 core runSummary
436
+ * 投影 / shared WorkflowRunStatus 同词)+ DoneReason(completed/failed/aborted/
437
+ * budget_limited/time_limited)+ ExecutionTraceNode.status(含 "pending"——trace
438
+ * 节点的初始态)。
439
439
  *
440
440
  * 收窄自 string → 显式联合,编译器会在新增 status 时强制 switch 补齐分支。
441
441
  */
442
442
  export type StatusText =
443
- | RunStatus
443
+ | "running"
444
444
  | "interrupted"
445
+ | "done"
445
446
  | DoneReason
446
447
  | "pending";
447
448
 
@@ -452,9 +453,11 @@ function statusColorToken(
452
453
  status: StatusText,
453
454
  ): "success" | "warning" | "error" | "muted" {
454
455
  switch (status) {
455
- case "completed": return "success";
456
+ // [§2.2 色档对齐] 预算耗尽 / 超时是失败族终局——与 formatStatusBadge 同档 error
457
+ //(此前本函数把它们归 muted,同一状态在徽标与圆点两处一个当错误一个当灰)。
458
+ case "failed": case "aborted": case "budget_limited": case "time_limited": return "error";
459
+ case "completed": case "done": return "success";
456
460
  case "running": return "warning";
457
- case "failed": case "aborted": return "error";
458
461
  default: return "muted";
459
462
  }
460
463
  }
@@ -472,9 +475,12 @@ export function formatStatusBadge(
472
475
  theme: ThemeLike,
473
476
  ): string {
474
477
  switch (status) {
478
+ // [§2.2 终局徽标修复] displayStatusOf(唯一生产入参)的终局值是 "done",此前无该
479
+ // 分支 → 落 default 显示 muted 裸字 "done";补成分支后与 completed 同形(success
480
+ // ✓ completed),TUI 头部不再出现无色的裸状态字。
481
+ case "done": case "completed": return theme.fg("success", "\u2713 completed");
475
482
  case "running": return theme.fg("warning", "\u25CF running");
476
483
  case "interrupted": return theme.fg("muted", "\u25CB interrupted");
477
- case "completed": return theme.fg("success", "\u2713 completed");
478
484
  case "failed": return theme.fg("error", "\u2717 failed");
479
485
  case "aborted": return theme.fg("error", "\u2717 aborted");
480
486
  case "budget_limited": return theme.fg("error", "\u26A0 budget");
@@ -1,4 +1,4 @@
1
- // src/interface/id-preview.ts
1
+ // src/interface/format/id-preview.ts
2
2
  //
3
3
  // run/subagent 标识符的截断展示口径(单点常量)。
4
4
  //
@@ -1,4 +1,4 @@
1
- // src/interface/bg-notify-render.ts
1
+ // src/interface/gui/bg-notify-render.ts
2
2
  //
3
3
  // background 完成通知的对话流渲染器。
4
4
  // pi.registerMessageRenderer("subagent-bg-notify", ...) 注册。
@@ -31,8 +31,8 @@ import {
31
31
  shortId,
32
32
  statusGlyph,
33
33
  type ThemeLike,
34
- } from "./format.ts";
35
- import { padToVisible, truncLine } from "./tui-kit.ts";
34
+ } from "../format/format.ts";
35
+ import { padToVisible, truncLine } from "../tui/tui-kit.ts";
36
36
 
37
37
  /** agent 名最大显示宽度。 */
38
38
  const AGENT_MAX_WIDTH = 40;
@@ -213,50 +213,75 @@ function sanitizeAnsiForBg(text: string): string {
213
213
  * - cancelled: `■ default — background subagent cancelled - bg-f6f731-10`
214
214
  * 第 2 行(正文):结果首行 / Error 首行 / cancelled 无第二行
215
215
  */
216
- function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
217
- const glyph = statusGlyph(record.status);
216
+ /**
217
+ * 标题行组装(renderRecordLines 拆出):glyph 与 verb 只读 outcome 分流 + model 段
218
+ * (空则省略,向后兼容旧 record)。
219
+ *
220
+ * [§2.2 通知字形修复] 字形与 verb 同源、只读 outcome:终态 record 的 status 恒为
221
+ * "closed"(statusGlyph 对 closed 返回 ✓ success),此前失败通知会画绿勾 + "failed"
222
+ * 文案自相矛盾。running 记录(无 outcome)仍走 statusGlyph。
223
+ */
224
+ function recordHeadLine(record: BgNotifyRecord, outcome: RecordOutcome, t: ThemeLike): string {
225
+ const glyph =
226
+ outcome === "failed"
227
+ ? { icon: "✗", color: "error" as const }
228
+ : outcome === "cancelled"
229
+ ? { icon: "■", color: "muted" as const }
230
+ : statusGlyph(record.status);
218
231
  const icon = glyph.icon ?? "•";
219
232
  const agent = truncLine(displayAgentName(record.agent), AGENT_MAX_WIDTH);
220
233
  // model 段:agent 后、状态描述前,accent 色。空则省略(向后兼容旧 record)。
221
234
  const modelPart = record.model
222
235
  ? ` ${t.fg("dim", "·")} ${t.fg("accent", truncLine(record.model, MODEL_MAX_WIDTH))}`
223
236
  : "";
237
+ const verb = outcome === "cancelled" ? "cancelled" : outcome === "failed" ? "failed" : "finished";
238
+ const idShort = shortId(record.id);
239
+ return `${t.fg(glyph.color, icon)} ${t.bold(agent)}${modelPart}${t.fg("dim", ` — background subagent ${verb} - ${idShort}`)}`;
240
+ }
241
+
242
+ /**
243
+ * closed 态正文行组装(renderRecordLines 拆出):cancelled 无正文(空数组);
244
+ * failed 显示 Error 首行;其余结果首行 / patch 提示([MF#1])。空数组由调用方
245
+ * 与 head 拼接(单行 = 仅标题)。
246
+ */
247
+ function closedBodyLines(record: BgNotifyRecord, outcome: RecordOutcome, t: ThemeLike): string[] {
248
+ if (outcome === "cancelled") return [];
249
+ if (outcome === "failed") {
250
+ return [t.fg("dim", truncLine(`Error: ${firstLineSanitized(record.error ?? "")}`, BODY_MAX_WIDTH))];
251
+ }
252
+ if (!record.result && !record.patchFile) return [];
253
+ const lines: string[] = [];
254
+ if (record.result) {
255
+ lines.push(t.fg("dim", truncLine(firstLineSanitized(record.result), BODY_MAX_WIDTH)));
256
+ }
257
+ // [MF#1] 显示 patch 路径提示(与 LLM content 同源),让用户也能看到改动需 `git apply`。
258
+ if (record.patchFile) {
259
+ lines.push(t.fg("dim", truncLine(`patch: ${record.patchFile} (run: git apply)`, BODY_MAX_WIDTH)));
260
+ }
261
+ return lines;
262
+ }
263
+
264
+ /**
265
+ * 渲染单条 record 为多行文本(纯视觉文本,不含边框/背景——由 BorderedBgBox 包裹)。
266
+ *
267
+ * 格式(两行):
268
+ * 第 1 行(标题):✓/✗/■ glyph + agent + model + 状态描述 + shortId
269
+ * - done: `✓ default — background subagent finished - bg-f6f731-10`
270
+ * - failed: `✗ default — background subagent failed - bg-f6f731-10`
271
+ * - cancelled: `■ default — background subagent cancelled - bg-f6f731-10`
272
+ * 第 2 行(正文):结果首行 / Error 首行 / cancelled 无第二行
273
+ * (组装拆见 recordHeadLine / closedBodyLines。)
274
+ */
275
+ function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
224
276
  // U3 C-outcome:verb 与正文分流只读 outcome——单一权威派生(升级前旧消息重放等
225
277
  // details 缺 outcome 的存量形态经 deriveOutcome(closedReason, error) 兜底,非同构
226
278
  // 重写)。判定先于 patchFile:failed 分支不展示 patch/result(失败轮也会写
227
279
  // patchFile,历史 bug 存档见 deriveOutcome 注释)。
228
280
  const outcome = record.outcome ?? deriveOutcome(record.closedReason, record.error);
229
- let verb: string;
230
- if (outcome === "cancelled") {
231
- verb = "cancelled";
232
- } else if (outcome === "failed") {
233
- verb = "failed";
234
- } else {
235
- verb = "finished";
236
- }
237
- const idShort = shortId(record.id);
238
- const head = `${t.fg(glyph.color, icon)} ${t.bold(agent)}${modelPart}${t.fg("dim", ` — background subagent ${verb} - ${idShort}`)}`;
239
-
281
+ const head = recordHeadLine(record, outcome, t);
240
282
  switch (record.status) {
241
- case "closed": {
242
- // U3 C-outcome:cancelled 无正文;failed 显示错误;否则结果/patch(同上,分流只读 outcome)。
243
- if (outcome === "cancelled") {
244
- return [head];
245
- }
246
- if (outcome === "failed") {
247
- return [head, t.fg("dim", truncLine(`Error: ${firstLineSanitized(record.error ?? "")}`, BODY_MAX_WIDTH))];
248
- }
249
- if (!record.result && !record.patchFile) return [head];
250
- const lines: string[] = [];
251
- if (record.result) {
252
- lines.push(t.fg("dim", truncLine(firstLineSanitized(record.result), BODY_MAX_WIDTH)));
253
- }
254
- // [MF#1] 显示 patch 路径提示(与 LLM content 同源),让用户也能看到改动需 `git apply`。
255
- if (record.patchFile) {
256
- lines.push(t.fg("dim", truncLine(`patch: ${record.patchFile} (run: git apply)`, BODY_MAX_WIDTH)));
257
- }
258
- return [head, ...lines];
259
- }
283
+ case "closed":
284
+ return [head, ...closedBodyLines(record, outcome, t)];
260
285
  case "running":
261
286
  default:
262
287
  // v4 B-1: 对话模式轮次完成(旧 idle 折入 running)——仅标题行。
@@ -0,0 +1,74 @@
1
+ /**
2
+ * GUI 协议映射辅助函数 —— run/subagent 状态字符串 → 协议 TreeItem 状态 + 图标。
3
+ *
4
+ * 协议包 @zhushanwen/extension-protocol 的 list-tree 组件用 TreeItem.status(三态)
5
+ * + TreeItem.icon 表达运行态。本模块把**子代理执行状态**(ExecutionStatus)映射到
6
+ * 这两个枚举,供 subagent-actions.ts 复用。
7
+ *
8
+ * 参考:@zhushanwen/extension-protocol GuiComponentProps['list-tree']。
9
+ */
10
+
11
+ import type { ExecutionStatus } from "@zhushanwen/subagent-core";
12
+ import type { GuiContext, TreeItem, TreeItemIcon } from "@zhushanwen/extension-protocol";
13
+
14
+ /**
15
+ * 从 Pi ExtensionContext 构造协议 GuiContext 的最小子集。
16
+ *
17
+ * Pi SDK 的 ExtensionContext 在结构上满足协议 GuiContext(有 mode/hasUI/ui),
18
+ * 但 ui.custom 的泛型签名与协议 GuiContext.ui.custom 不兼容(前者复杂泛型,后者
19
+ * 简化签名),直接 `as GuiContext` 会触发 TS 结构兼容错误(ui.custom 参数逆变)。
20
+ * 此 helper 显式提取 mode/hasUI,构造最小 GuiContext,规避 ui.custom 签名冲突。
21
+ *
22
+ * 与 ask-user extension 的 runRpcInteraction 同构(见 ask-user/src/index.ts)。
23
+ */
24
+ export function toGuiCtx(ctx: { mode: GuiContext["mode"]; hasUI: boolean } | undefined): GuiContext | undefined {
25
+ if (!ctx) return undefined;
26
+ return { mode: ctx.mode, hasUI: ctx.hasUI };
27
+ }
28
+
29
+ /** TreeItem.status 枚举(协议三态)。 */
30
+ type TreeStatus = NonNullable<TreeItem["status"]>;
31
+
32
+ /**
33
+ * [§2.2 入参收窄] 本模块服务的状态域 = **子代理执行状态**(`ExecutionStatus`
34
+ * "running" | "idle",唯一生产调用方 subagent-actions.ts 传的就是 SubagentListItem
35
+ * .status)。
36
+ *
37
+ * 历史形态是裸 string + 关键词子串匹配(failed/abort/cancel/crash/error/budget/
38
+ * time_limited),宣称服务 workflow run 状态——但 workflow 路径早已无调用方,那些
39
+ * 失败关键词分支在生产**全部不可达**,用例也只是在测死词表。收窄到显式联合后,
40
+ * 新增状态值会在编译期强制补齐分支(与 format.ts 的 statusGlyph 同款做法)。
41
+ */
42
+ type SubagentDisplayStatus = ExecutionStatus;
43
+
44
+ /** 子代理状态 → 协议三态 / 图标(穷尽 switch,无 default 静默兜底)。 */
45
+ function displayIndexOf(status: SubagentDisplayStatus): { tree: TreeStatus; icon: TreeItemIcon } {
46
+ switch (status) {
47
+ // running = 有任务在飞;idle = 无进行中工作(可续聊)——「为什么停」不进树形状态
48
+ //(协议无该维度),空闲落 done/check。
49
+ case "running": return { tree: "running", icon: "circle" };
50
+ case "idle": return { tree: "done", icon: "check" };
51
+ // 运行时兜底:类型层已排除域外值,但 extension 入参来自宿主/存量数据,可能带
52
+ // 旧词表(如 done/failed)——落 done/check(与收窄前的 default 行为一致),
53
+ // 不抛错(树形渲染不该因一个状态字崩掉整棵列表)。
54
+ default: return { tree: "done", icon: "check" };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 子代理执行状态 → list-tree 三态 status。
60
+ *
61
+ * running → running;idle → done(空闲 = 无进行中工作,check 图标)
62
+ */
63
+ export function mapRunStatus(status: SubagentDisplayStatus): TreeStatus {
64
+ return displayIndexOf(status).tree;
65
+ }
66
+
67
+ /**
68
+ * 子代理执行状态 → TreeItem.icon。
69
+ *
70
+ * running → circle;idle → check
71
+ */
72
+ export function mapRunIcon(status: SubagentDisplayStatus): TreeItemIcon {
73
+ return displayIndexOf(status).icon;
74
+ }
@@ -1,4 +1,4 @@
1
- // src/interface/subagent-actions.ts
1
+ // src/interface/gui/subagent-actions.ts
2
2
  //
3
3
  // subagent tool 六 action 的 TUI/GUI 渲染壳(sink 设计 D6② 消费收缩)。
4
4
  //
@@ -25,8 +25,8 @@ import type {
25
25
  SubagentToolResult,
26
26
  } from "@zhushanwen/subagent-core";
27
27
  import { mapRunIcon, mapRunStatus } from "./gui-mappers.ts";
28
- import { ID_PREVIEW_LENGTH } from "./id-preview.ts";
29
- import { withGuiAttach } from "./tool-shared.ts";
28
+ import { ID_PREVIEW_LENGTH } from "../format/id-preview.ts";
29
+ import { withGuiAttach } from "../tool/tool-shared.ts";
30
30
 
31
31
  // ============================================================
32
32
  // 渲染层常量 / 类型(pi TUI 渲染族,按设计留壳)