@zhushanwen/pi-pending-notifications 0.8.5 → 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 +8 -2
- package/src/__tests__/summary.test.ts +151 -0
- package/src/index.ts +32 -4
- package/src/render-result.ts +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-pending-notifications",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Cross-extension async operation registration/query mechanism for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -30,12 +30,18 @@
|
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
33
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
33
34
|
"typebox": "1.3.27"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@zhushanwen/extension-protocol": "0.
|
|
37
|
+
"@zhushanwen/extension-protocol": "0.21.0",
|
|
37
38
|
"@zhushanwen/pi-extension-logger": "0.6.2"
|
|
38
39
|
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@earendil-works/pi-tui": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
39
45
|
"scripts": {
|
|
40
46
|
"typecheck": "npx tsc --noEmit",
|
|
41
47
|
"test": "vitest run"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// pending_notifications 收起态摘要测试:buildPendingNotificationsSummary 纯函数文案 +
|
|
2
|
+
// execute 产出的 details.summary + renderResult 折叠/展开形态。
|
|
3
|
+
//
|
|
4
|
+
// mock 形态沿用 pending-notifications.test.ts(最小 ExtensionAPI/ExtensionContext mock)。
|
|
5
|
+
|
|
6
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import pendingNotificationsExtension, { buildPendingNotificationsSummary } from "../index";
|
|
10
|
+
import { renderPendingNotificationsResult } from "../render-result";
|
|
11
|
+
|
|
12
|
+
// ── mock(最小 ExtensionAPI / ExtensionContext)──────
|
|
13
|
+
|
|
14
|
+
function setup() {
|
|
15
|
+
const registerToolMock = vi.fn();
|
|
16
|
+
const entries: Array<{ customType: string; data: Record<string, unknown> }> = [];
|
|
17
|
+
const pi = {
|
|
18
|
+
appendEntry: vi.fn((customType: string, data: Record<string, unknown>) => {
|
|
19
|
+
entries.push({ customType, data });
|
|
20
|
+
}),
|
|
21
|
+
registerTool: registerToolMock,
|
|
22
|
+
sendMessage: vi.fn(),
|
|
23
|
+
on: vi.fn(),
|
|
24
|
+
events: { emit: vi.fn(), on: vi.fn() },
|
|
25
|
+
};
|
|
26
|
+
pendingNotificationsExtension(pi as never);
|
|
27
|
+
const ctx = {
|
|
28
|
+
sessionManager: {
|
|
29
|
+
getEntries: () => entries,
|
|
30
|
+
getSessionId: () => "sess-current",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return { registerToolMock, entries, ctx };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function runTool(
|
|
37
|
+
setup: ReturnType<typeof setup>,
|
|
38
|
+
params: { action: "count" | "list" },
|
|
39
|
+
): Promise<{ content: Array<{ type: string; text: string }>; details: Record<string, unknown> }> {
|
|
40
|
+
const tool = setup.registerToolMock.mock.calls[0]![0] as {
|
|
41
|
+
execute: (
|
|
42
|
+
toolCallId: string,
|
|
43
|
+
p: unknown,
|
|
44
|
+
signal: AbortSignal | undefined,
|
|
45
|
+
onUpdate: unknown,
|
|
46
|
+
ctx: unknown,
|
|
47
|
+
) => Promise<{ content: Array<{ type: string; text: string }>; details: unknown }>;
|
|
48
|
+
};
|
|
49
|
+
const res = await tool.execute("test-call-id", params, undefined, undefined, setup.ctx);
|
|
50
|
+
return { ...res, details: res.details as Record<string, unknown> };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 注入 2 条活跃 pending entry(register 落盘、无 unregister) */
|
|
54
|
+
function seedActive(setup: ReturnType<typeof setup>, ids: string[]): void {
|
|
55
|
+
for (const id of ids) {
|
|
56
|
+
setup.entries.push({
|
|
57
|
+
customType: "pending:register",
|
|
58
|
+
data: { id, type: "workflow", name: `op-${id}`, registeredAt: 0, sessionId: "sess-current" },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── buildPendingNotificationsSummary ────────────────
|
|
64
|
+
|
|
65
|
+
describe("buildPendingNotificationsSummary", () => {
|
|
66
|
+
it("count → N pending operations", () => {
|
|
67
|
+
expect(buildPendingNotificationsSummary({ action: "count", count: 2 })).toBe(
|
|
68
|
+
"2 pending operations",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("list → N entries listed", () => {
|
|
73
|
+
expect(buildPendingNotificationsSummary({ action: "list", count: 4 })).toBe(
|
|
74
|
+
"4 entries listed",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("零值 → No pending operations(与 content 文案零值口径一致)", () => {
|
|
79
|
+
expect(buildPendingNotificationsSummary({ action: "count", count: 0 })).toBe(
|
|
80
|
+
"No pending operations",
|
|
81
|
+
);
|
|
82
|
+
expect(buildPendingNotificationsSummary({ action: "list", count: 0 })).toBe(
|
|
83
|
+
"No pending operations",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── execute 产出的 details.summary ───────────────────
|
|
89
|
+
|
|
90
|
+
describe("pending_notifications execute details.summary", () => {
|
|
91
|
+
it("count action → details 带现有字段 + summary", async () => {
|
|
92
|
+
const s = setup();
|
|
93
|
+
const res = await runTool(s, { action: "count" });
|
|
94
|
+
expect(res.details).toMatchObject({ action: "count", count: 0, summary: "No pending operations" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("list action → details 带现有字段 + summary", async () => {
|
|
98
|
+
const s = setup();
|
|
99
|
+
seedActive(s, ["a", "b", "c", "d"]);
|
|
100
|
+
const res = await runTool(s, { action: "list" });
|
|
101
|
+
expect(res.details).toMatchObject({ action: "list", count: 4, summary: "4 entries listed" });
|
|
102
|
+
expect(Array.isArray(res.details.items)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── renderPendingNotificationsResult(TUI renderResult)──
|
|
107
|
+
|
|
108
|
+
const mockTheme = {
|
|
109
|
+
fg: (_color: string, text: string) => text,
|
|
110
|
+
bg: (_color: string, text: string) => text,
|
|
111
|
+
bold: (text: string) => text,
|
|
112
|
+
italic: (text: string) => text,
|
|
113
|
+
underline: (text: string) => text,
|
|
114
|
+
inverse: (text: string) => text,
|
|
115
|
+
strikethrough: (text: string) => text,
|
|
116
|
+
getFgAnsi: (_color: string) => "",
|
|
117
|
+
getBgAnsi: (_color: string) => "",
|
|
118
|
+
getColorMode: () => "truecolor" as const,
|
|
119
|
+
getThinkingBorderColor: () => (text: string) => text,
|
|
120
|
+
getBashModeBorderColor: () => (text: string) => text,
|
|
121
|
+
} as unknown as Theme;
|
|
122
|
+
|
|
123
|
+
function renderedText(result: unknown, expanded: boolean): string {
|
|
124
|
+
return renderPendingNotificationsResult(result, { expanded }, mockTheme)
|
|
125
|
+
.render(400)
|
|
126
|
+
.map((line) => line.trimEnd())
|
|
127
|
+
.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("renderPendingNotificationsResult", () => {
|
|
131
|
+
it("折叠态 → details.summary 一行", () => {
|
|
132
|
+
const r = {
|
|
133
|
+
content: [{ type: "text", text: "2 pending operation(s):\n- [workflow] a" }],
|
|
134
|
+
details: { action: "count", count: 2, summary: "2 pending operations" },
|
|
135
|
+
};
|
|
136
|
+
expect(renderedText(r, false)).toBe("2 pending operations");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("展开态 → content 纯文本", () => {
|
|
140
|
+
const r = {
|
|
141
|
+
content: [{ type: "text", text: "line1\nline2" }],
|
|
142
|
+
details: { action: "list", count: 2, summary: "2 entries listed" },
|
|
143
|
+
};
|
|
144
|
+
expect(renderedText(r, true)).toBe("line1\nline2");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("无 summary(旧 entry)→ 折叠态回退 content 首行", () => {
|
|
148
|
+
const r = { content: [{ type: "text", text: "2 pending operation(s):\n- [workflow] a" }], details: {} };
|
|
149
|
+
expect(renderedText(r, false)).toBe("2 pending operation(s):");
|
|
150
|
+
});
|
|
151
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* workflow 侧通过 deps.eventBus 注入 pi.events(同一总线)。
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
50
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
51
51
|
import { mapReasonToStatus as mapReasonToStatusImpl } from "@zhushanwen/extension-protocol";
|
|
52
52
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
53
53
|
import { Type } from "typebox";
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
type PendingStatus,
|
|
62
62
|
type PendingType,
|
|
63
63
|
} from "./state.ts";
|
|
64
|
+
import { renderPendingNotificationsResult } from "./render-result.ts";
|
|
64
65
|
|
|
65
66
|
// 跨扩展消费 API:goal(continuation 守卫)/ subagent-workflow(agent_end 后代判定)
|
|
66
67
|
// 直接 import 本包导出的 countActiveFromEntries,避免各自复制差集逻辑。导出面只保留
|
|
@@ -84,11 +85,13 @@ const PendingNotificationsParams = Type.Object({
|
|
|
84
85
|
});
|
|
85
86
|
|
|
86
87
|
/** pending_notifications 工具的 details 形状。两个分支共用同一结构(items 缺省时 undefined),
|
|
87
|
-
* 让 registerTool 泛型对 TDetails 推断一致,避免 union 细节冲突。
|
|
88
|
+
* 让 registerTool 泛型对 TDetails 推断一致,避免 union 细节冲突。summary 是收起态一行摘要
|
|
89
|
+
* (GUI 收起态与 TUI 折叠态共用,buildPendingNotificationsSummary 产出)。 */
|
|
88
90
|
interface PendingToolDetails {
|
|
89
91
|
action: string;
|
|
90
92
|
count: number;
|
|
91
93
|
items?: PendingEntry[];
|
|
94
|
+
summary: string;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/**
|
|
@@ -241,16 +244,29 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
241
244
|
if (params.action === "count") {
|
|
242
245
|
return {
|
|
243
246
|
content: [{ type: "text" as const, text: `${active.length} pending operation(s)` }],
|
|
244
|
-
details: {
|
|
247
|
+
details: {
|
|
248
|
+
action: "count",
|
|
249
|
+
count: active.length,
|
|
250
|
+
summary: buildPendingNotificationsSummary({ action: "count", count: active.length }),
|
|
251
|
+
},
|
|
245
252
|
};
|
|
246
253
|
}
|
|
247
254
|
|
|
248
255
|
// action === "list"
|
|
249
256
|
return {
|
|
250
257
|
content: [{ type: "text" as const, text: formatList(active) }],
|
|
251
|
-
details: {
|
|
258
|
+
details: {
|
|
259
|
+
action: "list",
|
|
260
|
+
count: active.length,
|
|
261
|
+
items: active,
|
|
262
|
+
summary: buildPendingNotificationsSummary({ action: "list", count: active.length }),
|
|
263
|
+
},
|
|
252
264
|
};
|
|
253
265
|
},
|
|
266
|
+
|
|
267
|
+
renderResult(result: unknown, options: { expanded: boolean }, theme: Theme) {
|
|
268
|
+
return renderPendingNotificationsResult(result, options, theme);
|
|
269
|
+
},
|
|
254
270
|
});
|
|
255
271
|
}
|
|
256
272
|
|
|
@@ -307,3 +323,15 @@ function formatList(active: PendingEntry[]): string {
|
|
|
307
323
|
const lines = active.map((op) => `- [${op.type}] ${op.name} (id=${op.id})`);
|
|
308
324
|
return `${active.length} pending operation(s):\n${lines.join("\n")}`;
|
|
309
325
|
}
|
|
326
|
+
|
|
327
|
+
/** 收起态一行摘要:count → "N pending operations";list → "N entries listed";零值统一
|
|
328
|
+
* "No pending operations"(与 content 文案的零值口径一致)。 */
|
|
329
|
+
export function buildPendingNotificationsSummary(details: {
|
|
330
|
+
action: string;
|
|
331
|
+
count: number;
|
|
332
|
+
}): string {
|
|
333
|
+
if (details.count === 0) return "No pending operations";
|
|
334
|
+
return details.action === "count"
|
|
335
|
+
? `${details.count} pending operations`
|
|
336
|
+
: `${details.count} entries listed`;
|
|
337
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pending_notifications 工具的 TUI renderResult(折叠态一行摘要 / 展开态 content 纯文本)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
7
|
+
import { firstContentText } from "@zhushanwen/extension-protocol";
|
|
8
|
+
|
|
9
|
+
/** content[0] 的 text 纯文本(content 缺失或非 text 类型时兜底空串)。 */
|
|
10
|
+
function contentText(result: unknown): string {
|
|
11
|
+
const content = fieldValue(result, "content");
|
|
12
|
+
if (!Array.isArray(content)) return "";
|
|
13
|
+
return firstContentText({ content });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** unknown 值按字段名安全取值(index-signature 收窄,替代全可选结构断言)。 */
|
|
17
|
+
function fieldValue(v: unknown, key: string): unknown {
|
|
18
|
+
if (typeof v !== "object" || v === null) return undefined;
|
|
19
|
+
return (v as Record<string, unknown>)[key];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 从 details 安全读取 summary(容错非对象形态与旧版本 entry)。 */
|
|
23
|
+
function readDetailsSummary(result: unknown): string | undefined {
|
|
24
|
+
const summary = fieldValue(fieldValue(result, "details"), "summary");
|
|
25
|
+
return typeof summary === "string" && summary !== "" ? summary : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 折叠态回退:content 文本首行(保持一行摘要形态)。 */
|
|
29
|
+
function firstLine(text: string): string {
|
|
30
|
+
const idx = text.indexOf("\n");
|
|
31
|
+
return idx === -1 ? text : text.slice(0, idx);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 折叠态(expanded=false)= details.summary 一行(dim);展开态 = content 纯文本。 */
|
|
35
|
+
export function renderPendingNotificationsResult(
|
|
36
|
+
result: unknown,
|
|
37
|
+
options: { expanded: boolean },
|
|
38
|
+
theme: Theme,
|
|
39
|
+
): Text {
|
|
40
|
+
if (options.expanded) return new Text(contentText(result), 0, 0);
|
|
41
|
+
const summary = readDetailsSummary(result) ?? firstLine(contentText(result));
|
|
42
|
+
return new Text(theme.fg("dim", summary), 0, 0);
|
|
43
|
+
}
|