@zhushanwen/pi-goal 0.8.2 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token/time budgets, blocked detection, and steering templates.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,7 +26,7 @@
26
26
  "@earendil-works/pi-coding-agent": "*",
27
27
  "@earendil-works/pi-tui": "*",
28
28
  "typebox": "*",
29
- "@zhushanwen/pi-pending-notifications": "0.3.1"
29
+ "@zhushanwen/pi-pending-notifications": "0.3.2"
30
30
  },
31
31
  "dependencies": {
32
32
  "@xyz-agent/extension-protocol": "^0.4.0"
@@ -1,15 +1,11 @@
1
1
  /**
2
- * goal_control tool — execute handler RPC 分支 handler 级测试(W3 Wave __gui__ 协议)
3
- *
4
- * PR review 补测:registerGoalControlTool execute handler 中
5
- * `if (ctx.mode === "rpc" && session.state)` 分支和 __gui__ 注入。
2
+ * goal_control tool — execute handler 级测试(M17 后:tool result 无 __gui__)
6
3
  *
7
4
  * 覆盖场景:
8
- * - RPC 模式 + session.state 非 null → details.__gui__ 存在(card 或 stats-line)
9
- * - RPC 模式 + session.state = null → 跳过路径守卫生效(前置 handler throw,验证分支不可达)
10
- * - TUI 模式 → __gui__ 不附加
11
- * - RPC 模式 + 有 budget → __gui__ 为 card 类型(progress-bar + stats-line)
12
- * - RPC 模式 + 无 budget → __gui__ 为 stats-line 类型
5
+ * - RPC 模式 create/complete/report_blocked → details 无 __gui__ 字段(状态展示改由
6
+ * handle* 内 updateWidget 经 guiSetWidget 推送 M17 对话流 widget 面板)
7
+ * - RPC 模式 + session.state = null → 前置 handler throw(分支不可达)
8
+ * - 非 RPC 模式(tui/json/print)→ details 同样无 __gui__,content 文本正常
13
9
  *
14
10
  * 范式参考 ask-user/src/__tests__/index.test.ts R-1~R-7(handler 级:注册 → 捕获 → 直接调 execute)。
15
11
  * 不 mock handleCreate:走真实 createGoal 让 session.state 含完整字段(slug/objective/budget),
@@ -117,47 +113,36 @@ function createViaHandler(
117
113
 
118
114
  // ── 测试场景 ─────────────────────────────────────────
119
115
 
120
- describe("goal_control execute — RPC __gui__ 注入分支", () => {
121
- it("RPC + 有 state(有 budget)→ details.__gui__ 存在且为 card 类型", async () => {
116
+ describe("goal_control execute — RPC 模式 tool result 无 __gui__", () => {
117
+ it("RPC + 有 budget 的 create → details 无 __gui__ 字段(content 仍正常)", async () => {
122
118
  const { pi, ctx } = makeFixture("rpc");
123
119
  const tool = captureTool(pi);
124
- // create 后 session.state 有 tokenBudget → buildGoalGui 走 card 分支
125
120
  const result = await createViaHandler(tool, pi, ctx, {
126
121
  slug: "rpc-card",
127
122
  objective: "rpc card goal",
128
123
  tokenBudget: 10000,
129
124
  });
130
- const gui = result.details.__gui__!;
131
- expect(gui.component.type).toBe("card");
132
- // card body 含 progress-bar(tokenBudget)+ stats-line
133
- const body = gui.component.props.body as Array<{ type: string }>;
134
- const types = body.map((c) => c.type);
135
- expect(types).toContain("progress-bar");
136
- expect(types).toContain("stats-line");
125
+ expect("__gui__" in result.details).toBe(false);
126
+ expect(result.details.action).toBe("create");
127
+ expect(result.details.status).toBe("active");
128
+ expect(result.content[0].text).toContain("Goal created");
137
129
  });
138
130
 
139
- it("RPC + 有 state(无 budget)→ details.__gui__ 存在且为 stats-line 类型", async () => {
131
+ it("RPC + 无 budget 的 create → details 无 __gui__ 字段", async () => {
140
132
  const { pi, ctx } = makeFixture("rpc");
141
133
  const tool = captureTool(pi);
142
- // create 不传 budget → buildGoalGui 走 stats-line 分支
143
134
  const result = await createViaHandler(tool, pi, ctx, {
144
135
  slug: "rpc-stats",
145
136
  objective: "rpc stats goal",
146
137
  });
147
- const gui = result.details.__gui__!;
148
- expect(gui.component.type).toBe("stats-line");
149
- // stats-line items 含 goal/status/turn/tokens
150
- const items = gui.component.props.items as Array<{ label: string }>;
151
- const labels = items.map((i) => i.label);
152
- expect(labels).toContain("goal");
153
- expect(labels).toContain("status");
138
+ expect("__gui__" in result.details).toBe(false);
139
+ expect(result.details.action).toBe("create");
154
140
  });
155
141
 
156
- it("RPC + session.state = null → 跳过路径守卫生效(前置 handler throw,分支不可达)", async () => {
157
- // 源码 line 380: `if (ctx.mode === "rpc" && session.state)` 的 session.state 守卫。
142
+ it("RPC + session.state = null → 前置 handler throw(分支不可达)", async () => {
158
143
  // execute 路径上,create 总会 set session.state;complete/report_blocked 前置守卫
159
- // 要求 session.state 非 null 否则 throw——所以 session.state=null 时分支条件恒不成立。
160
- // 验证:全新 session(state=null)下,complete 前置守卫先 throw,不进 __gui__ 注入逻辑。
144
+ // 要求 session.state 非 null 否则 throw——所以 session.state=null 时不会走到返回路径。
145
+ // 验证:全新 session(state=null)下,complete 前置守卫先 throw。
161
146
  const { pi, ctx } = makeFixture("rpc");
162
147
  let captured: CapturedTool | undefined;
163
148
  const capturePi = {
@@ -168,7 +153,7 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
168
153
  } as unknown as ExtensionAPI;
169
154
  registerGoalControlTool(capturePi, createGoalSession());
170
155
  const tool2 = captured!;
171
- // complete 在 state=null 时 throw(handleComplete 前置守卫)—— 不会走到 __gui__ 分支
156
+ // complete 在 state=null 时 throw(handleComplete 前置守卫)
172
157
  await expect(
173
158
  tool2.execute(
174
159
  "id",
@@ -180,13 +165,12 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
180
165
  ).rejects.toThrow(/not active/i);
181
166
  });
182
167
 
183
- it("RPC + complete 动作 → __gui__ 也附加(用终态 state 渲染)", async () => {
184
- // 验证 __gui__ 注入不限于 create:complete 后 session.state 仍非 null(status=complete)
168
+ it("RPC + complete 动作 → details 无 __gui__ 字段(终态 status 正确)", async () => {
185
169
  const { pi, ctx } = makeFixture("rpc");
186
170
  const tool = captureTool(pi);
187
171
  // 先 create(建 active state)
188
172
  await createViaHandler(tool, pi, ctx, { slug: "comp", objective: "to complete" });
189
- // 再 complete —— session.state.status=complete,buildGoalGui 走 card variant=success
173
+ // 再 complete —— session.state.status=complete
190
174
  const result = await tool.execute(
191
175
  "call-2",
192
176
  { action: "complete", evidence: "tests green" },
@@ -195,15 +179,10 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
195
179
  ctx,
196
180
  );
197
181
  expect(result.details.status).toBe("complete");
198
- const gui = result.details.__gui__!;
199
- // 无 budget → stats-line
200
- expect(gui.component.type).toBe("stats-line");
201
- const items = gui.component.props.items as Array<{ label: string; value: string }>;
202
- const statusItem = items.find((i) => i.label === "status");
203
- expect(statusItem!.value).toBe("complete");
182
+ expect("__gui__" in result.details).toBe(false);
204
183
  });
205
184
 
206
- it("RPC + report_blocked 动作 → __gui__ 附加,status severity danger", async () => {
185
+ it("RPC + report_blocked 动作 → details 无 __gui__ 字段(blocked status 正确)", async () => {
207
186
  const { pi, ctx } = makeFixture("rpc");
208
187
  const tool = captureTool(pi);
209
188
  await createViaHandler(tool, pi, ctx, { slug: "blk", objective: "to block" });
@@ -215,21 +194,16 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
215
194
  ctx,
216
195
  );
217
196
  expect(result.details.status).toBe("blocked");
218
- // 无 budget → stats-line;blocked status severity=danger
219
- const gui = result.details.__gui__!;
220
- expect(gui.component.type).toBe("stats-line");
221
- const items = gui.component.props.items as Array<{ label: string; severity?: string }>;
222
- const statusItem = items.find((i) => i.label === "status");
223
- expect(statusItem!.severity).toBe("danger");
197
+ expect("__gui__" in result.details).toBe(false);
224
198
  });
225
199
 
226
200
  });
227
201
 
228
- // ── 边界:非 RPC 模式(tui/json/print)也不注入 __gui__ ──
202
+ // ── 边界:非 RPC 模式(tui/json/print)同样无 __gui__ ──
229
203
 
230
- describe("goal_control execute — 非 RPC 模式不注入 __gui__", () => {
204
+ describe("goal_control execute — 非 RPC 模式无 __gui__", () => {
231
205
  const NON_RPC_MODES: Array<"tui" | "json" | "print"> = ["tui", "json", "print"];
232
- it.each(NON_RPC_MODES)("%s 模式 → __gui__ 不附加(content 仍正常)", async (mode) => {
206
+ it.each(NON_RPC_MODES)("%s 模式 → details 无 __gui__ 字段(content 仍正常)", async (mode) => {
233
207
  const { pi, ctx } = makeFixture(mode);
234
208
  const tool = captureTool(pi);
235
209
  const result = await createViaHandler(tool, pi, ctx, {
@@ -237,7 +211,7 @@ describe("goal_control execute — 非 RPC 模式不注入 __gui__", () => {
237
211
  objective: `${mode} goal`,
238
212
  tokenBudget: 1000,
239
213
  });
240
- expect(result.details.__gui__).toBeUndefined();
241
- expect(result.content[0]!.text).toContain("Goal created");
214
+ expect("__gui__" in result.details).toBe(false);
215
+ expect(result.content[0].text).toContain("Goal created");
242
216
  });
243
217
  });
@@ -1,11 +1,11 @@
1
1
  /**
2
- * buildGoalGui 测试 — goal 的 GUI 渲染描述符构造(W3 Wave __gui__ 协议)
2
+ * buildGoalGui 测试 — v1.1 meta head 架构。
3
3
  *
4
4
  * 覆盖:
5
- * - 有 tokenBudget/timeBudget → card(progress-bar + stats-line)
6
- * - 预算消耗阈值(≥70% warn, ≥90% danger)
7
- * - 状态→severity/variant 映射
8
- * - 无 budget → stats-line 摘要
5
+ * - 内容根 = group(透明组合容器):stats-line(status/turn[+tokens])+ list-tree(criteria)
6
+ * - meta:title=slug、状态点语义(done/failed/idle/running)、token 进度(取整 + 百分比 + 阈值 severity)
7
+ * - successCriteria 逐行 list-tree(不压扁/不截断/无 icon/不编号)
8
+ * - token 展示走 formatTokens(无 budget 分支)
9
9
  */
10
10
  import { describe, expect, it } from "vitest";
11
11
 
@@ -20,30 +20,58 @@ function makeState(overrides: Partial<GoalRuntimeState> = {}): GoalRuntimeState
20
20
  };
21
21
  }
22
22
 
23
- /** 从 card 分支的 body 中按 type 取组件(hasBudget 分支只有一个 progress-bar / 一个 stats-line 组合)。 */
24
- function findBodyComp(
23
+ /** 从 group children 中按 type 取组件(每个类型至多一个)。 */
24
+ function findChildComp(
25
25
  gui: ReturnType<typeof buildGoalGui>,
26
26
  type: string,
27
27
  ): { type: string; props: Record<string, unknown> } {
28
- const body = gui.component.props.body as { type: string; props: Record<string, unknown> }[];
29
- return body.find((c) => c.type === type)!;
28
+ if (gui.component.type !== "group") throw new Error(`root 应为 group,实际 ${gui.component.type}`);
29
+ const children = gui.component.props.children as { type: string; props: Record<string, unknown> }[];
30
+ const found = children.find((c) => c.type === type);
31
+ if (!found) throw new Error(`children 中无 ${type} 组件`);
32
+ return found;
30
33
  }
31
34
 
32
- describe("buildGoalGui", () => {
33
- it("有 tokenBudget → card 含 progress-bar + stats-line", () => {
35
+ describe("buildGoalGui(v1.1 meta head 架构)", () => {
36
+ it("内容根 = group(stats-line + list-tree),无 card(卡壳/head 归宿主壳层)", () => {
34
37
  const gui = buildGoalGui(
35
38
  makeState({
36
39
  tokensUsed: 4200,
37
40
  budget: { tokenBudget: 10000 },
38
41
  currentTurnIndex: 3,
42
+ successCriteria: "all tests green",
39
43
  }),
40
44
  );
41
45
  expect(gui.v).toBe(1);
42
- expect(gui.component.type).toBe("card");
43
- const tokenBar = findBodyComp(gui, "progress-bar");
44
- expect(tokenBar.props).toMatchObject({ current: 4200, total: 10000, severity: "ok" });
45
- const stats = findBodyComp(gui, "stats-line");
46
+ expect(gui.component.type).toBe("group");
47
+ const stats = findChildComp(gui, "stats-line");
46
48
  expect(stats.props.items).toContainEqual(expect.objectContaining({ label: "status", value: "active" }));
49
+ expect(findChildComp(gui, "list-tree")).toBeDefined();
50
+ // 无 progress-bar(token 进度移入 head meta)
51
+ expect(() => findChildComp(gui, "progress-bar")).toThrow();
52
+ });
53
+
54
+ it("meta:title=slug、status=running、progress=取整 current + 百分比 label + 阈值 severity", () => {
55
+ const gui = buildGoalGui(
56
+ makeState({
57
+ slug: "fix-auth",
58
+ tokensUsed: 4200,
59
+ budget: { tokenBudget: 10000 },
60
+ }),
61
+ );
62
+ expect(gui.meta).toEqual({
63
+ title: "fix-auth",
64
+ status: "running",
65
+ progress: { current: 4200, total: 10000, label: "42%", severity: "ok" },
66
+ });
67
+ });
68
+
69
+ it("tokensUsed 浮点 → meta.progress.current 取整(1454.84…1 不进 UI)", () => {
70
+ const gui = buildGoalGui(
71
+ makeState({ tokensUsed: 1454.8400000000001, budget: { tokenBudget: 5000 } }),
72
+ );
73
+ expect(gui.meta!.progress!.current).toBe(1455);
74
+ expect(gui.meta!.progress!.label).toBe("29%");
47
75
  });
48
76
 
49
77
  it.each([
@@ -51,111 +79,96 @@ describe("buildGoalGui", () => {
51
79
  ["9000/10000 =90%", 9000, "danger"],
52
80
  ["7500/10000 ≥70%", 7500, "warn"],
53
81
  ["7000/10000 =70%", 7000, "warn"],
54
- ])("token 消耗 %s → severity %s(S#14 边界 ≥)", (_label, used, severity) => {
82
+ ])("token 消耗 %s → meta.progress.severity %s(S#14 边界 ≥)", (_label, used, severity) => {
55
83
  const gui = buildGoalGui(makeState({ tokensUsed: used, budget: { tokenBudget: 10000 } }));
56
- const tokenBar = findBodyComp(gui, "progress-bar");
57
- expect(tokenBar.props.severity).toBe(severity);
84
+ expect(gui.meta!.progress!.severity).toBe(severity);
58
85
  });
59
86
 
60
- it("无 budget → stats-line 摘要", () => {
87
+ it.each([
88
+ ["complete", "done"],
89
+ ["blocked", "failed"],
90
+ ["budget_limited", "failed"],
91
+ ["cancelled", "failed"],
92
+ ["paused", "idle"],
93
+ ])("status %s → meta.status %s", (status, metaStatus) => {
61
94
  const gui = buildGoalGui(
62
- makeState({
63
- currentTurnIndex: 5,
64
- tokensUsed: 3000,
65
- }),
95
+ makeState({ status: status as GoalStatus, budget: { tokenBudget: 10000 } }),
66
96
  );
67
- expect(gui.component.type).toBe("stats-line");
68
- const items = gui.component.props.items as { label?: string }[];
69
- const labels = items.map((i) => i.label);
70
- expect(labels).toContain("goal");
71
- expect(labels).toContain("status");
72
- expect(labels).toContain("turn");
73
- expect(labels).toContain("tokens");
97
+ expect(gui.meta!.status).toBe(metaStatus);
74
98
  });
75
99
 
76
- it("blocked 状态 → card variant danger", () => {
100
+ it("无 budget → stats-line 含 tokens(formatTokens,3k),meta 无 progress", () => {
77
101
  const gui = buildGoalGui(
78
102
  makeState({
79
- status: "blocked",
80
- budget: { tokenBudget: 10000 },
103
+ currentTurnIndex: 5,
104
+ tokensUsed: 3000,
81
105
  }),
82
106
  );
83
- expect(gui.component.type).toBe("card");
84
- expect(gui.component.props.variant).toBe("danger");
107
+ expect(gui.meta).toEqual({ title: expect.any(String), status: "running" });
108
+ const stats = findChildComp(gui, "stats-line");
109
+ const items = stats.props.items as { label?: string; value?: string }[];
110
+ expect(items.find((i) => i.label === "tokens")).toMatchObject({ value: "3k" });
85
111
  });
86
112
 
87
- it("complete 状态 → card variant success", () => {
88
- const gui = buildGoalGui(
89
- makeState({
90
- status: "complete",
91
- budget: { tokenBudget: 10000 },
92
- }),
93
- );
94
- expect(gui.component.type).toBe("card");
95
- expect(gui.component.props.variant).toBe("success");
113
+ it("有 budget 时 stats-line 不重复 tokens(head 进度已表达)", () => {
114
+ const gui = buildGoalGui(makeState({ tokensUsed: 3000, budget: { tokenBudget: 10000 } }));
115
+ const stats = findChildComp(gui, "stats-line");
116
+ const labels = (stats.props.items as { label?: string }[]).map((i) => i.label);
117
+ expect(labels).not.toContain("tokens");
96
118
  });
97
119
 
98
- it("slug 缺省 → header 用 goalId 前 8 字符", () => {
120
+ it("slug 缺省 → meta.title 用 goalId 前 8 字符", () => {
99
121
  const state = makeState({ slug: undefined, budget: { tokenBudget: 10000 } });
100
- const gui = buildGoalGui(state);
101
- expect(gui.component.props.header).toBe(state.goalId.slice(0, 8));
122
+ expect(buildGoalGui(state).meta!.title).toBe(state.goalId.slice(0, 8));
102
123
  });
103
124
 
104
- // ── S#2: statusSeverity 完整覆盖 ──
105
-
106
125
  it.each([
107
126
  ["budget_limited", "danger"],
108
127
  ["cancelled", "danger"],
109
128
  ["paused", "warn"],
110
129
  ])("status %s → stats-line status severity %s(S#2)", (status, severity) => {
111
130
  const gui = buildGoalGui(makeState({ status: status as GoalStatus, budget: { tokenBudget: 10000 } }));
112
- const stats = findBodyComp(gui, "stats-line");
131
+ const stats = findChildComp(gui, "stats-line");
113
132
  const items = stats.props.items as Array<{ label: string; severity?: string }>;
114
133
  const statusItem = items.find((i) => i.label === "status")!;
115
134
  expect(statusItem.severity).toBe(severity);
116
135
  });
117
136
 
118
- // ── I#1: tokenBudget=0 口径统一 ──
119
-
120
- it("tokenBudget=0 → 无 progress-bar(口径 >0,I#1)", () => {
137
+ it("tokenBudget=0 → 无预算形态(meta 无 progress,stats-line 含 tokens)(I#1 口径 >0)", () => {
121
138
  const gui = buildGoalGui(makeState({ tokensUsed: 0, budget: { tokenBudget: 0 } }));
122
- // tokenBudget=0 → hasBudget=false → 走无 budget 分支的 stats-line
123
- expect(gui.component.type).toBe("stats-line");
124
- });
125
-
126
- // ── 无 budget 分支的 status severity ──
127
-
128
- it("无 budget 时 status severity 正确(S#14)", () => {
129
- const gui = buildGoalGui(makeState({ status: "active" }));
130
- const items = gui.component.props.items as Array<{ label: string; severity?: string }>;
131
- const statusItem = items.find((i) => i.label === "status");
132
- expect(statusItem!.severity).toBe("ok");
139
+ expect(gui.meta!.progress).toBeUndefined();
140
+ const stats = findChildComp(gui, "stats-line");
141
+ const labels = (stats.props.items as { label?: string }[]).map((i) => i.label);
142
+ expect(labels).toContain("tokens");
133
143
  });
134
144
 
135
- // ── successCriteria 渲染(与 objective 成对,让用户看到「怎么算完成」)──
145
+ // ── successCriteria 逐行 list-tree ──
136
146
 
137
- it("card 分支(有 budget)+ successCriteria → body 含 stats-line {label:'done'}", () => {
147
+ it("多行 successCriteria → list-tree 每行一条纯文本 item(用户报障场景)", () => {
138
148
  const gui = buildGoalGui(
139
149
  makeState({
140
- successCriteria: "all tests green",
150
+ successCriteria: "1. 输出 Node.js 版本号;\n2. 输出 extensions 目录下的子目录列表",
141
151
  budget: { tokenBudget: 10000 },
142
152
  }),
143
153
  );
144
- expect(gui.component.type).toBe("card");
145
- const body = gui.component.props.body as {
146
- type: string;
147
- props: { items: Array<{ label: string; value: string }> };
148
- }[];
149
- const doneStats = body.find(
150
- (c) => c.type === "stats-line" && c.props.items.some((i) => i.label === "done"),
151
- )!;
152
- expect(doneStats.props.items).toContainEqual({ label: "done", value: "all tests green" });
154
+ const tree = findChildComp(gui, "list-tree");
155
+ // 无 icon(所有行同 icon 是无信息量装饰)、不编号(criteria 文本常自带编号)
156
+ expect(tree.props.numbered).toBeUndefined();
157
+ expect(tree.props.items).toEqual([
158
+ { label: "1. 输出 Node.js 版本号;", depth: 0 },
159
+ { label: "2. 输出 extensions 目录下的子目录列表", depth: 0 },
160
+ ]);
153
161
  });
154
162
 
155
- it("无 budget 分支 + successCriteria → items 含 {label:'done'}", () => {
156
- const gui = buildGoalGui(makeState({ successCriteria: "all tests green" }));
157
- expect(gui.component.type).toBe("stats-line");
158
- const items = gui.component.props.items as Array<{ label: string; value: string }>;
159
- expect(items).toContainEqual({ label: "done", value: "all tests green" });
163
+ it("超 80 字符的 criteria 行不截断;空行过滤;无 criteria → 无 list-tree", () => {
164
+ const longLine = "x".repeat(120);
165
+ const longGui = buildGoalGui(makeState({ successCriteria: longLine }));
166
+ expect(findChildComp(longGui, "list-tree").props.items).toEqual([{ label: longLine, depth: 0 }]);
167
+
168
+ const gapGui = buildGoalGui(makeState({ successCriteria: "a\n\n \nb" }));
169
+ expect(findChildComp(gapGui, "list-tree").props.items).toHaveLength(2);
170
+
171
+ const noneGui = buildGoalGui(makeState({ successCriteria: undefined, budget: { tokenBudget: 10000 } }));
172
+ expect(() => findChildComp(noneGui, "list-tree")).toThrow();
160
173
  });
161
174
  });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * adapters/ports.ts 测试 — UiPort GUI 协议接线(marker 编码集成)。
3
+ *
4
+ * 验证 buildPorts 构造的 uiPort:
5
+ * - setGuiWidget 经 guiSetWidget 编码 marker 进 ctx.ui.setWidget(RPC 模式)
6
+ * - isGui 反映 ctx.mode(rpc → GUI 渲染通道;tui → 原生文本)
7
+ *
8
+ * 运行:cd extensions/goal && npx vitest run src/__tests__/ports.test.ts
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { GUI_WIDGET_MARKER, guiComponent } from "@xyz-agent/extension-protocol";
12
+
13
+ import { buildPorts } from "../adapters/ports";
14
+
15
+ /** mock pi(buildPorts 仅用 pi.appendEntry,此处不测 persistence)。 */
16
+ function makePi(): Parameters<typeof buildPorts>[0] {
17
+ return { appendEntry: () => {} } as unknown as Parameters<typeof buildPorts>[0];
18
+ }
19
+
20
+ /** mock ctx,捕获 ui.setWidget 收到的 lines(验证 marker 编码)。 */
21
+ function makeCtx(opts: { mode?: string; hasUI?: boolean } = {}) {
22
+ let captured: string[] | undefined = undefined;
23
+ const ctx = {
24
+ mode: opts.mode ?? "rpc",
25
+ hasUI: opts.hasUI ?? true,
26
+ ui: {
27
+ setWidget: (_key: string, lines: string[] | undefined) => {
28
+ captured = lines;
29
+ },
30
+ setStatus: () => {},
31
+ notify: () => {},
32
+ theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
33
+ },
34
+ } as unknown as Parameters<typeof buildPorts>[1];
35
+ return { ctx, getCaptured: () => captured };
36
+ }
37
+
38
+ describe("buildPorts UiPort.setGuiWidget(marker 编码集成)", () => {
39
+ it("RPC 模式 setGuiWidget → ctx.ui.setWidget 收到 marker 编码的 string[]", () => {
40
+ const { ctx, getCaptured } = makeCtx({ mode: "rpc" });
41
+ const ports = buildPorts(makePi(), ctx);
42
+ const comp = guiComponent("stats-line", { items: [{ value: "x" }] });
43
+
44
+ ports.ui.setGuiWidget("goal", comp);
45
+
46
+ const captured = getCaptured();
47
+ expect(captured).toBeDefined();
48
+ expect(captured).toHaveLength(1);
49
+ // marker 前缀(host 侧 event-adapter 据此解码成 extension:widgetGui)
50
+ expect(captured![0].startsWith(GUI_WIDGET_MARKER)).toBe(true);
51
+ // 解码还原 GuiComponent
52
+ const json = captured![0].slice(GUI_WIDGET_MARKER.length);
53
+ const parsed = JSON.parse(json);
54
+ expect(parsed.type).toBe("stats-line");
55
+ });
56
+
57
+ it("setGuiWidget(undefined) → ctx.ui.setWidget 收到 undefined(清除语义)", () => {
58
+ const { ctx, getCaptured } = makeCtx({ mode: "rpc" });
59
+ const ports = buildPorts(makePi(), ctx);
60
+
61
+ ports.ui.setGuiWidget("goal", undefined);
62
+
63
+ expect(getCaptured()).toBeUndefined();
64
+ });
65
+ });
66
+
67
+ describe("buildPorts UiPort.isGui(ctx.mode 反映)", () => {
68
+ it("rpc 模式 → isGui=true(GUI 渲染通道有效)", () => {
69
+ const { ctx } = makeCtx({ mode: "rpc" });
70
+ expect(buildPorts(makePi(), ctx).ui.isGui).toBe(true);
71
+ });
72
+
73
+ it("tui 模式 → isGui=false(走原生文本行)", () => {
74
+ const { ctx } = makeCtx({ mode: "tui" });
75
+ expect(buildPorts(makePi(), ctx).ui.isGui).toBe(false);
76
+ });
77
+ });
@@ -33,13 +33,11 @@
33
33
 
34
34
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
35
35
  import { Text } from "@earendil-works/pi-tui";
36
- import { type GuiRenderResult } from "@xyz-agent/extension-protocol";
37
36
  import { type Static, Type } from "typebox";
38
37
 
39
38
  import { SHORT_ID_LENGTH } from "../constants";
40
39
  import { isActiveStatus, isTerminalStatus, transitionStatus } from "../engine/goal";
41
40
  import type { BudgetConfig, GoalStatus } from "../engine/types";
42
- import { buildGoalGui } from "../projection/gui";
43
41
  import { updateWidget } from "../projection/widget";
44
42
  import { createGoal, finalizeAndPersist, persistState, type ServicePorts, tickState } from "../service";
45
43
  import type { GoalSession } from "../session";
@@ -111,8 +109,6 @@ export interface GoalControlDetails {
111
109
  goalId: string;
112
110
  status: GoalStatus;
113
111
  slug?: string;
114
- /** RPC 模式下的 GUI 渲染描述符(progress-bar 预算进度)。TUI 模式无此字段。 */
115
- __gui__?: GuiRenderResult;
116
112
  }
117
113
 
118
114
  // ── 业务 handler(契约对齐 §3,可测:fake ports)──────
@@ -344,10 +340,8 @@ export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession):
344
340
  ? `Goal completed.\nGoal ID: ${details.goalId}`
345
341
  : `Goal reported blocked.\nGoal ID: ${details.goalId}\nReason: ${params.reason?.trim() ?? ""}`;
346
342
 
347
- // RPC 模式下附加 __gui__(用展开避免 details 来自 frozen 对象时加字段失败)
348
- if (ctx.mode === "rpc" && session.state) {
349
- return { content: [{ type: "text", text }], details: { ...details, __gui__: buildGoalGui(session.state) } };
350
- }
343
+ // 状态展示不再进 tool result(GUI 渲染字段已移除):GUI 由 handle* 内的 updateWidget
344
+ // 经 guiSetWidget 推送(M17 对话流 widget 面板)。
351
345
  return { content: [{ type: "text", text }], details };
352
346
  },
353
347
 
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI, ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
13
+ import { guiSetWidget, isGuiCapable } from "@xyz-agent/extension-protocol";
14
+ import type { GuiContext, GuiRenderResult } from "@xyz-agent/extension-protocol";
13
15
 
14
16
  import { ENTRY_TYPE, HISTORY_ENTRY_TYPE } from "../persistence";
15
17
  import type { MessagingPort, PersistencePort, SessionPort, UiPort } from "../ports";
@@ -45,6 +47,14 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
45
47
  const lines = typeof content === "string" ? [content] : content;
46
48
  ctx.ui.setWidget(name, lines);
47
49
  },
50
+ setGuiWidget(name: string, result: GuiRenderResult | undefined): void {
51
+ // GUI 协议 widget:guiSetWidget 在 RPC 模式用 marker 编码 GuiRenderResult JSON
52
+ // (component + meta 宿主元数据)进 string[],复用 ctx.ui.setWidget 通道;
53
+ // host 侧 event-adapter 检测 marker 解码还原。guiSetWidget 无 isGui 守卫
54
+ // (helpers.ts 仅查 ctx.ui?.setWidget 存在性),isGui 判定在 updateWidget
55
+ // 外层(projection/widget.ts)。
56
+ guiSetWidget(ctx as unknown as GuiContext, name, result);
57
+ },
48
58
  setStatus(name: string, text: string | undefined): void {
49
59
  ctx.ui.setStatus(name, text);
50
60
  },
@@ -54,6 +64,10 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
54
64
  get hasUI(): boolean {
55
65
  return Boolean(ctx.hasUI);
56
66
  },
67
+ get isGui(): boolean {
68
+ // RPC 模式 = GUI 渲染通道有效;TUI/json/print 走 pi 原生渲染
69
+ return isGuiCapable(ctx as unknown as GuiContext);
70
+ },
57
71
  // ThemeLike 形状:透传 ctx.ui.theme 的 fg/bold。
58
72
  // Theme.fg 只接受 ThemeColor 字面量 union(SDK 契约);projection 层保证传入 union 内字面量,
59
73
  // string → ThemeColor 是宽到窄单步断言(无需 unknown 中转)。
package/src/ports.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { GoalRuntimeState } from "./engine/types";
10
+ import type { GuiRenderResult } from "@xyz-agent/extension-protocol";
10
11
 
11
12
  // ── GoalHistoryEntry(DTO,非 aggregate,D-09)─────────
12
13
 
@@ -36,12 +37,20 @@ export interface PersistencePort {
36
37
  export interface UiPort {
37
38
  /** 设置 widget(undefined = 清除)。hasUI=false 时 adapter 跳过(FR-6.6) */
38
39
  setWidget(name: string, content: string[] | string | undefined): void;
40
+ /**
41
+ * 设置 GUI 协议 widget(GuiRenderResult:component + meta 宿主元数据,
42
+ * RPC 模式经 guiSetWidget marker 编码)。TUI 模式 adapter 内部 no-op
43
+ * (走 setWidget 文本行)。undefined = 清除。
44
+ */
45
+ setGuiWidget(name: string, result: GuiRenderResult | undefined): void;
39
46
  /** 设置 status bar */
40
47
  setStatus(name: string, text: string | undefined): void;
41
48
  /** 弹通知 */
42
49
  notify(text: string, level: "info" | "warning" | "error"): void;
43
- /** 是否有 UI(headless/RPC mode 为 false) */
50
+ /** 是否有 UI(headless 为 false) */
44
51
  readonly hasUI: boolean;
52
+ /** 是否 GUI 渲染模式(RPC → GuiComponent;TUI → 原生文本行) */
53
+ readonly isGui: boolean;
45
54
  }
46
55
 
47
56
  // ── MessagingPort ────────────────────────────────────
@@ -194,17 +194,21 @@ describe("renderWidgetLines", () => {
194
194
 
195
195
  describe("updateWidget (FR-6.6 hasUI guard)", () => {
196
196
  interface RecordedCall {
197
- method: "setWidget" | "setStatus";
197
+ method: "setWidget" | "setGuiWidget" | "setStatus";
198
198
  args: unknown[];
199
199
  }
200
200
 
201
- function makeUiPort(hasUI: boolean): { ui: UiPort; calls: RecordedCall[] } {
201
+ function makeUiPort(hasUI: boolean, isGui = false): { ui: UiPort; calls: RecordedCall[] } {
202
202
  const calls: RecordedCall[] = [];
203
203
  const ui = {
204
204
  hasUI,
205
+ isGui,
205
206
  setWidget(name: string, content: unknown) {
206
207
  calls.push({ method: "setWidget", args: [name, content] });
207
208
  },
209
+ setGuiWidget(name: string, component: unknown) {
210
+ calls.push({ method: "setGuiWidget", args: [name, component] });
211
+ },
208
212
  setStatus(name: string, text: unknown) {
209
213
  calls.push({ method: "setStatus", args: [name, text] });
210
214
  },
@@ -264,3 +268,83 @@ describe("updateWidget (FR-6.6 hasUI guard)", () => {
264
268
  );
265
269
  });
266
270
  });
271
+
272
+ // ── updateWidget GUI 协议分支(isGui=true,复用 projection/gui.ts buildGoalGui)──
273
+
274
+ describe("updateWidget GUI 协议分支(isGui=true)", () => {
275
+ interface RecordedCall {
276
+ method: "setWidget" | "setGuiWidget" | "setStatus";
277
+ args: unknown[];
278
+ }
279
+
280
+ function makeGuiUiPort(hasUI: boolean, isGui = false): { ui: UiPort; calls: RecordedCall[] } {
281
+ const calls: RecordedCall[] = [];
282
+ const ui = {
283
+ hasUI,
284
+ isGui,
285
+ setWidget(name: string, content: unknown) {
286
+ calls.push({ method: "setWidget", args: [name, content] });
287
+ },
288
+ setGuiWidget(name: string, component: unknown) {
289
+ calls.push({ method: "setGuiWidget", args: [name, component] });
290
+ },
291
+ setStatus(name: string, text: unknown) {
292
+ calls.push({ method: "setStatus", args: [name, text] });
293
+ },
294
+ notify() {},
295
+ fg: (_color: string, text: string) => text,
296
+ bold: (text: string) => text,
297
+ } as UiPort;
298
+ return { ui, calls };
299
+ }
300
+
301
+ it("active + isGui + 有预算 → setGuiWidget(v1.1 result: group + meta) + 不调 setWidget TUI lines", () => {
302
+ const { ui, calls } = makeGuiUiPort(true, true);
303
+ const session = createGoalSession();
304
+ session.state = makeState({ status: "active", budget: { tokenBudget: 10000 } });
305
+ updateWidget(session, ui);
306
+ const guiCall = calls.find((c) => c.method === "setGuiWidget" && c.args[0] === "goal");
307
+ expect(guiCall).toBeDefined();
308
+ // v1.1:整个 GuiRenderResult(group 组合根 + meta 宿主元数据,head 由壳层渲染)
309
+ const result = guiCall!.args[1] as {
310
+ component: { type: string };
311
+ meta: { title: string; progress?: { total: number } };
312
+ };
313
+ expect(result.component.type).toBe("group");
314
+ expect(result.meta.progress).toMatchObject({ total: 10000 });
315
+ // GUI 模式不走 TUI 文本行
316
+ expect(calls.some((c) => c.method === "setWidget" && c.args[0] === "goal")).toBe(false);
317
+ });
318
+
319
+ it("active + isGui 无预算 → setGuiWidget(group,meta 无 progress)", () => {
320
+ const { ui, calls } = makeGuiUiPort(true, true);
321
+ const session = createGoalSession();
322
+ session.state = makeState({ status: "active" });
323
+ updateWidget(session, ui);
324
+ const guiCall = calls.find((c) => c.method === "setGuiWidget" && c.args[0] === "goal");
325
+ expect(guiCall).toBeDefined();
326
+ const result = guiCall!.args[1] as {
327
+ component: { type: string };
328
+ meta: { title: string; progress?: unknown };
329
+ };
330
+ expect(result.component.type).toBe("group");
331
+ expect(result.meta.progress).toBeUndefined();
332
+ });
333
+
334
+ it("cancelled + isGui → setGuiWidget(undefined)", () => {
335
+ const { ui, calls } = makeGuiUiPort(true, true);
336
+ const session = createGoalSession();
337
+ session.state = makeState({ status: "cancelled" });
338
+ updateWidget(session, ui);
339
+ expect(calls.some((c) => c.method === "setGuiWidget" && c.args[1] === undefined)).toBe(true);
340
+ });
341
+
342
+ it("isGui=false → 走 TUI setWidget(不调 setGuiWidget)", () => {
343
+ const { ui, calls } = makeGuiUiPort(true, false);
344
+ const session = createGoalSession();
345
+ session.state = makeState({ status: "active" });
346
+ updateWidget(session, ui);
347
+ expect(calls.some((c) => c.method === "setWidget" && c.args[0] === "goal")).toBe(true);
348
+ expect(calls.some((c) => c.method === "setGuiWidget")).toBe(false);
349
+ });
350
+ });
@@ -2,22 +2,29 @@
2
2
  * GUI 渲染描述符构造(projection 层)— buildGoalGui + goalStatusSeverity
3
3
  *
4
4
  * H4 拆层:从 adapters/goal-control-adapter.ts 抽出。GUI 渲染描述符归 projection 层,
5
- * goal-control-adapter 回归 tool 注册 + RPC 模式调用本模块。
5
+ * goal-control-adapter 回归纯 tool 注册(M17 后不再调用本模块)。
6
6
  *
7
7
  * 与 projection/widget.ts 的分工:
8
8
  * - widget.ts:TUI 模式渲染(ANSI 字符串,经 ctx.ui.setWidget)
9
- * - gui.ts:RPC 模式渲染(结构化 GuiComponent 描述符,放进 details.__gui__)
9
+ * - gui.ts:RPC 模式渲染(结构化 GuiComponent 描述符,经 guiSetWidget 推送给 M17 对话流 widget 面板)
10
10
  *
11
11
  * 预算阈值经 engine/budget.ts 的 getBudgetSeverity 单源化(H4):
12
12
  * buildGoalGui(percent→severity)与 widget.getBudgetColor(percent→color)共用阈值。
13
13
  */
14
14
 
15
- import { type GuiComponent, guiComponent, type GuiRenderResult, guiResult } from "@xyz-agent/extension-protocol";
15
+ import {
16
+ type GuiComponent,
17
+ guiComponent,
18
+ type GuiRenderResult,
19
+ guiResult,
20
+ type TreeItem,
21
+ type WidgetMeta,
22
+ } from "@xyz-agent/extension-protocol";
16
23
 
17
- import { OBJECTIVE_DISPLAY_LIMIT, SHORT_ID_LENGTH } from "../constants";
24
+ import { PERCENT_FACTOR, SHORT_ID_LENGTH } from "../constants";
18
25
  import { getBudgetSeverity } from "../engine/budget";
19
26
  import type { GoalRuntimeState, GoalStatus } from "../engine/types";
20
- import { toSingleLine } from "./widget";
27
+ import { formatTokens } from "./widget";
21
28
 
22
29
  /**
23
30
  * 按 GoalStatus 映射 stats-line severity(S#2)。
@@ -44,83 +51,93 @@ export function goalStatusSeverity(status: GoalStatus): "ok" | "warn" | "danger"
44
51
  }
45
52
  }
46
53
 
54
+ /** GoalStatus → head 状态点语义(WidgetMeta.status)。 */
55
+ function metaStatus(status: GoalStatus): WidgetMeta["status"] {
56
+ switch (status) {
57
+ case "complete":
58
+ return "done";
59
+ case "blocked":
60
+ case "budget_limited":
61
+ case "cancelled":
62
+ return "failed";
63
+ case "paused":
64
+ return "idle";
65
+ case "active":
66
+ return "running";
67
+ }
68
+ }
69
+
47
70
  /**
48
- * 构造 goal 的 GUI 渲染描述符(RPC 模式下放进 details.__gui__)。
49
- *
50
- * 逻辑参考 projection/widget.ts 的 renderWidgetLines 预算计算,但此处只构造
51
- * 结构化数据(GuiComponent),不做 ANSI 渲染。
71
+ * successCriteria 按行拆分为非空 trim 行。
72
+ * 原实现用 toSingleLine 压扁 + 截断后塞进 stats-line 单行 value,多行验收标准
73
+ * 变成一坨长串,是 goal widget 可读性问题的根因——多行数据应改用 list-tree 多行呈现。
74
+ */
75
+ function splitCriteriaLines(criteria: string | undefined): string[] {
76
+ return criteria
77
+ ? criteria
78
+ .split(/\r?\n/)
79
+ .map((line) => line.trim())
80
+ .filter((line) => line.length > 0)
81
+ : [];
82
+ }
83
+
84
+ /**
85
+ * 构造 goal 的 GUI 渲染描述符(v1.1 meta head 架构)。
52
86
  *
53
- * - 有 tokenBudget → card(progress-bar + stats-line) 展示预算消耗
54
- * - 无 budget → stats-line 展示状态摘要
87
+ * - meta(标题=slug / 状态点 / token 进度)由宿主壳层渲染成唯一 head;
88
+ * 有预算时进度计数用百分比 + 预算阈值 severity(70/90 warn/danger 单源)。
89
+ * - 内容根 = group(透明组合容器):stats-line(status/turn,无预算时补 token
90
+ * 绝对值)+ list-tree(criteria 逐行,无 icon——所有行同 icon 是无信息量装饰,
91
+ * 不编号——criteria 文本常自带 "1. 2." 编号)。
55
92
  */
56
93
  export function buildGoalGui(state: GoalRuntimeState): GuiRenderResult {
57
94
  const slug = state.slug ?? state.goalId.slice(0, SHORT_ID_LENGTH);
58
- // successCriteria 摘要(截断后进 stats-line;与 objective 成对展示)
59
- const criteriaSnippet = state.successCriteria
60
- ? toSingleLine(state.successCriteria).slice(0, OBJECTIVE_DISPLAY_LIMIT)
61
- : undefined;
62
- // statusSeverity 按 GoalStatus 完整覆盖(S#2):
63
- // active/complete → ok;blocked → danger;paused → warn;
64
- // budget_limited/cancelled → danger(预算耗尽/取消是错误终态)
95
+ // statusSeverity 按 GoalStatus 完整覆盖(S#2),stats-line status item 消费
65
96
  const statusSeverity = goalStatusSeverity(state.status);
66
97
 
67
- // hasBudget 与进度条判定统一口径:用 > 0 而非 truthy(I#1:tokenBudget=0 不应触发 card 容器)
68
- const hasBudget = (state.budget.tokenBudget ?? 0) > 0;
98
+ // hasBudget 与进度判定统一口径:用 > 0 而非 truthy(I#1:tokenBudget=0 不应触发预算展示)
99
+ const tokenBudget = state.budget.tokenBudget;
100
+ const hasBudget = (tokenBudget ?? 0) > 0;
69
101
 
70
- if (hasBudget) {
71
- const body: GuiComponent[] = [];
72
- // token 进度条(>0 判定,与 hasBudget 口径一致)
73
- const tokenBudget = state.budget.tokenBudget;
74
- if ((tokenBudget ?? 0) > 0) {
75
- const tb = tokenBudget!;
76
- const tokenPct = state.tokensUsed / tb;
77
- body.push(
78
- guiComponent("progress-bar", {
79
- label: "tokens",
80
- current: state.tokensUsed,
81
- total: tb,
82
- unit: "tok",
83
- // H4:阈值经 getBudgetSeverity 单源化(原内联 percent→severity 三元)
84
- severity: getBudgetSeverity(tokenPct),
85
- }),
86
- );
87
- }
88
- // 状态 + turn 统计行
89
- body.push(
90
- guiComponent("stats-line", {
91
- items: [
92
- { label: "status", value: state.status, severity: statusSeverity },
93
- { label: "turn", value: String(state.currentTurnIndex) },
94
- ],
95
- }),
96
- );
97
- // successCriteria 摘要(与 objective 成对,让用户看到「怎么算完成」)
98
- if (criteriaSnippet) {
99
- body.push(
100
- guiComponent("stats-line", {
101
- items: [{ label: "done", value: criteriaSnippet }],
102
- }),
103
- );
104
- }
105
- return guiResult(
106
- guiComponent("card", {
107
- variant: state.status === "blocked" ? "danger" : state.status === "complete" ? "success" : "default",
108
- header: slug,
109
- body,
110
- }),
111
- );
112
- }
102
+ const children: GuiComponent[] = [];
113
103
 
114
- // 无 budget:stats-line 摘要
115
- return guiResult(
104
+ // 状态 + turn 统计行;无预算时补 token 绝对值(有预算时 head 进度已表达,不重复)。
105
+ // token 走 formatTokens(12000 → "12k"),避免浮点原样泄漏到 UI
106
+ children.push(
116
107
  guiComponent("stats-line", {
117
108
  items: [
118
- { label: "goal", value: slug },
119
109
  { label: "status", value: state.status, severity: statusSeverity },
120
110
  { label: "turn", value: String(state.currentTurnIndex) },
121
- { label: "tokens", value: String(state.tokensUsed) },
122
- ...(criteriaSnippet ? [{ label: "done", value: criteriaSnippet }] : []),
111
+ ...(hasBudget ? [] : [{ label: "tokens", value: formatTokens(state.tokensUsed) }]),
123
112
  ],
124
113
  }),
125
114
  );
115
+
116
+ // successCriteria 逐行呈现(用户看「怎么算完成」)
117
+ const criteriaLines = splitCriteriaLines(state.successCriteria);
118
+ if (criteriaLines.length > 0) {
119
+ children.push(
120
+ guiComponent("list-tree", {
121
+ items: criteriaLines.map((line): TreeItem => ({ label: line, depth: 0 })),
122
+ }),
123
+ );
124
+ }
125
+
126
+ const meta: WidgetMeta = {
127
+ title: slug,
128
+ status: metaStatus(state.status),
129
+ ...(hasBudget
130
+ ? {
131
+ progress: {
132
+ // tokensUsed 是累计加权浮点(如 1454.8400…1),取整后再进 UI
133
+ current: Math.round(state.tokensUsed),
134
+ total: tokenBudget!,
135
+ label: `${Math.round((state.tokensUsed / tokenBudget!) * PERCENT_FACTOR)}%`,
136
+ severity: getBudgetSeverity(state.tokensUsed / tokenBudget!),
137
+ },
138
+ }
139
+ : {}),
140
+ };
141
+
142
+ return guiResult(guiComponent("group", { children }), meta);
126
143
  }
@@ -28,6 +28,7 @@ import { isTerminalStatus } from "../engine/goal";
28
28
  import type { GoalRuntimeState } from "../engine/types";
29
29
  import type { UiPort } from "../ports";
30
30
  import type { GoalSession } from "../session";
31
+ import { buildGoalGui } from "./gui";
31
32
 
32
33
  /**
33
34
  * projection 层的 Theme 抽象。不 import Pi 的 ThemeColor。
@@ -220,16 +221,22 @@ export { asTheme };
220
221
  /**
221
222
  * 刷新 widget + status bar。
222
223
  *
223
- * FR-6.6:`uiPort.hasUI === false`(headless / RPC mode)时直接 return,
224
- * 不调 setWidget / setStatus,避免无 UI 环境崩溃或无意义写入。
224
+ * FR-6.6:`uiPort.hasUI === false`(headless)时直接 return。
225
225
  *
226
- * 终态折叠为单行 status bar;cancelled / 无 state 时清除 widget + status。
226
+ * GUI 模式(RPC,uiPort.isGui):推送 buildGoalGui 的 GuiComponent(guiSetWidget marker
227
+ * 编码,host 侧解码 → GuiComponentRenderer 渲染)。
228
+ * TUI 模式:推送 renderWidgetLines 文本行(向后兼容)。
229
+ *
230
+ * 终态折叠为单行 status bar(widget 清除);cancelled/无 state 清除 widget + status。
227
231
  */
228
232
  export function updateWidget(session: GoalSession, uiPort: UiPort): void {
229
233
  if (!uiPort.hasUI) return;
230
234
 
235
+ const isGui = uiPort.isGui;
236
+
231
237
  if (!session.state || session.state.status === "cancelled") {
232
- uiPort.setWidget("goal", undefined);
238
+ if (isGui) uiPort.setGuiWidget("goal", undefined);
239
+ else uiPort.setWidget("goal", undefined);
233
240
  uiPort.setStatus("goal", undefined);
234
241
  return;
235
242
  }
@@ -240,10 +247,16 @@ export function updateWidget(session: GoalSession, uiPort: UiPort): void {
240
247
  if (statusText) {
241
248
  uiPort.setStatus("goal", statusText);
242
249
  }
243
- uiPort.setWidget("goal", undefined);
250
+ if (isGui) uiPort.setGuiWidget("goal", undefined);
251
+ else uiPort.setWidget("goal", undefined);
244
252
  return;
245
253
  }
246
254
 
247
255
  uiPort.setStatus("goal", renderStatusLine(session.state, asTheme(uiPort)));
248
- uiPort.setWidget("goal", renderWidgetLines(session.state, asTheme(uiPort)));
256
+ if (isGui) {
257
+ // 复用 projection/gui.ts 的 buildGoalGui(整个 GuiRenderResult:component + meta 宿主元数据)
258
+ uiPort.setGuiWidget("goal", buildGoalGui(session.state));
259
+ } else {
260
+ uiPort.setWidget("goal", renderWidgetLines(session.state, asTheme(uiPort)));
261
+ }
249
262
  }