@zhushanwen/pi-permission 0.0.1 → 0.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.
- package/README.md +27 -4
- package/package.json +6 -2
- package/src/__tests__/footer-provider.test.ts +342 -0
- package/src/__tests__/index-integration.test.ts +102 -0
- package/src/__tests__/statusline-palette.test.ts +88 -0
- package/src/footer-provider.ts +193 -0
- package/src/index.ts +70 -21
- package/src/statusline-palette.ts +38 -0
- package/src/__tests__/statusline.test.ts +0 -221
- package/src/statusline.ts +0 -226
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Pi permission 扩展 — 四档权限模式(yolo / auto / approve / strict)+
|
|
|
12
12
|
- **AI Classifier**:auto 模式下用 LLM 评估未知命令风险(low/medium/high)
|
|
13
13
|
- **用户审批 UI**:TUI(自定义 Component)/ RPC(select 对话框)/ headless(fail-closed deny)
|
|
14
14
|
- **Reject-with-Reason**:用户拒绝时可输入真实理由(回传 agent 辅助理解)
|
|
15
|
-
- **statusline 集成**:TUI
|
|
15
|
+
- **statusline 集成**:TUI 底部 footer 显示当前权限模式标签(通过 globalThis Symbol 握手协议向 statusline 注册一行 footer line renderer)
|
|
16
16
|
- **fail-closed**:任何异常路径 → block(绝不静默放行)
|
|
17
17
|
|
|
18
18
|
## 安装
|
|
@@ -383,17 +383,40 @@ auto 模式下层 3 用 LLM 评估未知命令风险:
|
|
|
383
383
|
|
|
384
384
|
## statusline 集成
|
|
385
385
|
|
|
386
|
-
|
|
386
|
+
permission 不再自管 footer,而是通过 **globalThis Symbol 握手协议** 向 `@zhushanwen/pi-statusline` 注册一行 footer line renderer(`session_start` 时调用 `registerPermissionFooterLine`):
|
|
387
|
+
|
|
388
|
+
- **statusline 是 footer canonical owner**:唯一创建 footer registry(`getOrCreateFooterRegistry`)。
|
|
389
|
+
- **permission 是 consumer**:永不创建 registry 实例,永不写 `slot.registry` 字段;只通过 `FOOTER_HANDSHAKE_KEY = Symbol.for('@zhushanwen/pi-statusline.footerHandshake')` 读写 slot。
|
|
390
|
+
- **插入位置**:renderer `order=2`,聚合排序后落在 line2(model)和 line3(ctx)之间。
|
|
391
|
+
- **加载顺序无关**:slot 形状 `{version, registry?, pending:[]}`。registry 未就绪时 consumer 只 push pending,等 statusline 后到时 flush(沿用 ask-user 的 pending-flush 修复模式)。
|
|
392
|
+
- **无代码层 import**:permission 仅对 statusline 做运行时 `globalThis` 反射,statusline 是可选 `peerDep`,未安装时静态 import 不会破坏 permission。
|
|
393
|
+
- **重绘触发**:mode/enabled 切换后调用 `requestFooterRender()`(`REQUEST_RENDER_KEY`),statusline 立即重绘 footer。
|
|
394
|
+
|
|
395
|
+
footer line 内容(精简版,避免 footer 拥挤):
|
|
387
396
|
|
|
388
397
|
```
|
|
389
|
-
[
|
|
398
|
+
[permission] Auto · enabled
|
|
399
|
+
[permission] disabled
|
|
390
400
|
```
|
|
391
401
|
|
|
392
402
|
模式标签:YOLO / Auto / Approve / Strict(对应 yolo/auto/approve/strict)。
|
|
393
403
|
|
|
404
|
+
**未安装 statusline 时 silent 降级**:permission 功能完整,仅 footer 不显示 mode 标签。可用 `/permission status` 查看 mode。
|
|
405
|
+
|
|
406
|
+
## 升级须知
|
|
407
|
+
|
|
408
|
+
**v0.0.1 → v0.1.0 breaking change**:本扩展不再自管 footer,权限模式标签现在由 `@zhushanwen/pi-statusline` 聚合提供(通过 globalThis Symbol 握手协议)。此前 footer 由各扩展各自调用 `ctx.ui.setFooter` 单例渲染、互相覆盖。
|
|
409
|
+
|
|
410
|
+
- **同时安装 statusline 的用户**:无需任何操作。mode 标签自动作为 statusline footer 的一行出现(line2 和 line3 之间)。
|
|
411
|
+
- **未安装 statusline 的用户**:footer 不再显示 mode 标签。两种恢复方式:
|
|
412
|
+
- 安装 statusline:`pi install npm:@zhushanwen/pi-statusline`,mode 标签回到 footer。
|
|
413
|
+
- 或随时用 `/permission status` 查看/切换 mode(不依赖 footer)。
|
|
414
|
+
|
|
415
|
+
详见下方「statusline 集成」章节。
|
|
416
|
+
|
|
394
417
|
## 已知限制
|
|
395
418
|
|
|
396
|
-
- **Footer
|
|
419
|
+
- **Footer 由 statusline 聚合**:本扩展不再自管 footer,mode 标签通过 globalThis Symbol 握手协议向 `@zhushanwen/pi-statusline` 注册(见「statusline 集成」)。未安装 statusline 时 footer 不显示 mode 标签(silent 降级,不影响功能)。
|
|
397
420
|
- **TUI Reject-with-Reason**:当前 RPC 分支已完整接入 `ctx.ui.input` 采集拒绝理由;TUI 分支因 pi-tui Input 组件集成成本较高,暂保留简化 deny(固定文案),后续迭代补齐内联文本输入。
|
|
398
421
|
- **headless 模式**:json/print 模式无交互 UI,所有审批请求 fail-closed deny(不阻塞自动化流程,但 strict/approve 模式下无法放行)。
|
|
399
422
|
- **wasm 加载**:AST 分析依赖 tree-sitter-bash wasm,加载失败时 fail-closed(clean=false, parseError=true)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-permission",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Pi permission extension — four permission modes (yolo/auto/approve/strict) with three-layer pipeline (AST + rules + AI classifier).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@earendil-works/pi-ai": "*",
|
|
31
31
|
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
-
"@earendil-works/pi-tui": "*"
|
|
32
|
+
"@earendil-works/pi-tui": "*",
|
|
33
|
+
"@zhushanwen/pi-statusline": "*"
|
|
33
34
|
},
|
|
34
35
|
"peerDependenciesMeta": {
|
|
35
36
|
"@earendil-works/pi-ai": {
|
|
@@ -37,6 +38,9 @@
|
|
|
37
38
|
},
|
|
38
39
|
"@earendil-works/pi-tui": {
|
|
39
40
|
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"@zhushanwen/pi-statusline": {
|
|
43
|
+
"optional": true
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
"devDependencies": {
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* footer-provider.test.ts — consumer 端握手协议 + footer 渲染测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - TC1:slot 不存在 → consumer 建新 slot(仅 pending,registry 空)
|
|
6
|
+
* - TC2:slot 存在 + registry 就绪 → 直接 register(不走 pending)
|
|
7
|
+
* - TC3:slot 存在 + registry 未就绪 → push pending
|
|
8
|
+
* - TC4:dispose(registry 就绪路径)→ unregister + requestFooterRender
|
|
9
|
+
* - TC5:dispose(pending 路径)→ 从 pending 移除 + requestFooterRender
|
|
10
|
+
* - TC6:requestFooterRender(statusline 未安装)→ noop(不抛)
|
|
11
|
+
* - TC7:requestFooterRender(已注册 fn)→ 调用 fn
|
|
12
|
+
* - TC8:version mismatch → readSlot 返回 undefined + warn + ensureSlot 重建
|
|
13
|
+
* - TC13:dispose 幂等(重复调用安全)
|
|
14
|
+
* - 渲染:renderPermissionFooterLine 四档模式 + enabled/disabled
|
|
15
|
+
* - 字面量跨端一致:FOOTER_HANDSHAKE_KEY / REQUEST_RENDER_KEY 与 statusline owner 端 ===
|
|
16
|
+
*/
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
FOOTER_HANDSHAKE_KEY,
|
|
21
|
+
type FooterLineRenderer,
|
|
22
|
+
registerPermissionFooterLine,
|
|
23
|
+
renderPermissionFooterLine,
|
|
24
|
+
REQUEST_RENDER_KEY,
|
|
25
|
+
requestFooterRender,
|
|
26
|
+
} from "../footer-provider.js";
|
|
27
|
+
import type { PermissionPalette } from "../statusline-palette.js";
|
|
28
|
+
import type { PermissionMode } from "../types.js";
|
|
29
|
+
import { MODE_LABELS } from "../types.js";
|
|
30
|
+
|
|
31
|
+
// ──────────────────────── mock helpers ────────────────────────
|
|
32
|
+
|
|
33
|
+
/** 透传 palette(不加 ANSI,便于断言原始字符串)。 */
|
|
34
|
+
function passthroughPalette(): PermissionPalette {
|
|
35
|
+
return {
|
|
36
|
+
dim: (s) => s,
|
|
37
|
+
text: (s) => s,
|
|
38
|
+
accent: (s) => s,
|
|
39
|
+
success: (s) => s,
|
|
40
|
+
warning: (s) => s,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 构造最小 renderer(render 返回固定行)。 */
|
|
45
|
+
function makeRenderer(order = 2, line = "permission-line"): FooterLineRenderer {
|
|
46
|
+
return {
|
|
47
|
+
order,
|
|
48
|
+
render: () => line,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* mock registry 的显式返回类型。
|
|
54
|
+
*
|
|
55
|
+
* 结构上兼容 footer-provider.ts 的 FooterLineRegistry 接口(register/unregister),
|
|
56
|
+
* 额外暴露 has/calls 供断言。footer-provider.ts 的 FooterLineRegistry 未导出,
|
|
57
|
+
* 这里用结构等价契约绑定:若 footer-provider.ts 的 registry 接口签名漂移(如
|
|
58
|
+
* register 增加 id 规范化、unregister 改名),mock 会在传入 slot.registry 时
|
|
59
|
+
* 编译期暴露(slot 字面量的 registry 字段需满足该结构)。
|
|
60
|
+
*
|
|
61
|
+
* register 的 renderer 参数用导出的 FooterLineRenderer 替代原先的 unknown,
|
|
62
|
+
* 让 mock 与 consumer 端 renderer 契约漂移时编译期即可发现。
|
|
63
|
+
*/
|
|
64
|
+
interface MockFooterRegistry {
|
|
65
|
+
register(id: string, renderer: FooterLineRenderer): void;
|
|
66
|
+
unregister(id: string): void;
|
|
67
|
+
has(id: string): boolean;
|
|
68
|
+
calls: { op: "register" | "unregister"; id: string }[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** mock registry:记录 register/unregister 调用,内部 Map 存储。 */
|
|
72
|
+
function makeRegistry(): MockFooterRegistry {
|
|
73
|
+
const map = new Map<string, FooterLineRenderer>();
|
|
74
|
+
const calls: { op: "register" | "unregister"; id: string }[] = [];
|
|
75
|
+
return {
|
|
76
|
+
register(id: string, renderer: FooterLineRenderer): void {
|
|
77
|
+
calls.push({ op: "register", id });
|
|
78
|
+
map.set(id, renderer);
|
|
79
|
+
},
|
|
80
|
+
unregister(id: string): void {
|
|
81
|
+
calls.push({ op: "unregister", id });
|
|
82
|
+
map.delete(id);
|
|
83
|
+
},
|
|
84
|
+
has: (id: string): boolean => map.has(id),
|
|
85
|
+
calls,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 读当前 slot(version 守卫后)。 */
|
|
90
|
+
function getRawSlot(): unknown {
|
|
91
|
+
return Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 清理 globalThis 上的握手 slot + requestRender fn(每个测试隔离)。 */
|
|
95
|
+
function cleanGlobalThis(): void {
|
|
96
|
+
Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
97
|
+
Reflect.deleteProperty(globalThis, REQUEST_RENDER_KEY);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
cleanGlobalThis();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
cleanGlobalThis();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ──────────────────────── TC1:slot 不存在 → consumer 建新 slot(pending) ────────────────────────
|
|
109
|
+
|
|
110
|
+
describe("TC1:slot 不存在 → consumer 建新 slot(仅 pending,registry 空)", () => {
|
|
111
|
+
it("无 slot 时注册 → 新 slot 含 pending 一项,registry 未定义", () => {
|
|
112
|
+
expect(getRawSlot()).toBeUndefined();
|
|
113
|
+
registerPermissionFooterLine(makeRenderer());
|
|
114
|
+
const slot = getRawSlot() as { version: number; registry?: unknown; pending: unknown[] };
|
|
115
|
+
expect(slot).toBeDefined();
|
|
116
|
+
expect(slot.version).toBe(1);
|
|
117
|
+
expect(slot.registry).toBeUndefined(); // consumer 永不创建 registry
|
|
118
|
+
expect(slot.pending).toHaveLength(1);
|
|
119
|
+
expect((slot.pending[0] as { id: string }).id).toBe("pi-permission");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ──────────────────────── TC2:slot 存在 + registry 就绪 → 直接 register ────────────────────────
|
|
124
|
+
|
|
125
|
+
describe("TC2:slot 存在 + registry 就绪 → 直接 register(不走 pending)", () => {
|
|
126
|
+
it("registry 已就绪 → 调 registry.register,pending 不变", () => {
|
|
127
|
+
const registry = makeRegistry();
|
|
128
|
+
// 预置 owner 端就绪的 slot(registry 已填)
|
|
129
|
+
Reflect.set(globalThis, FOOTER_HANDSHAKE_KEY, {
|
|
130
|
+
version: 1,
|
|
131
|
+
registry,
|
|
132
|
+
pending: [],
|
|
133
|
+
});
|
|
134
|
+
registerPermissionFooterLine(makeRenderer());
|
|
135
|
+
expect(registry.has("pi-permission")).toBe(true);
|
|
136
|
+
expect(registry.calls.filter((c) => c.op === "register")).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ──────────────────────── TC3:slot 存在 + registry 未就绪 → push pending ────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("TC3:slot 存在 + registry 未就绪 → push pending", () => {
|
|
143
|
+
it("slot 存在但 registry 空 → 新 entry 进 pending(不调 register)", () => {
|
|
144
|
+
// 预置一个 consumer 先建的空 slot(pending 已有别的 entry)
|
|
145
|
+
Reflect.set(globalThis, FOOTER_HANDSHAKE_KEY, {
|
|
146
|
+
version: 1,
|
|
147
|
+
pending: [{ id: "other", renderer: makeRenderer(0) }],
|
|
148
|
+
});
|
|
149
|
+
registerPermissionFooterLine(makeRenderer());
|
|
150
|
+
const slot = getRawSlot() as { pending: { id: string }[] };
|
|
151
|
+
expect(slot.pending).toHaveLength(2);
|
|
152
|
+
expect(slot.pending.some((p) => p.id === "pi-permission")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ──────────────────────── TC4:dispose(registry 就绪)→ unregister ────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("TC4:dispose(registry 就绪路径)→ unregister + requestFooterRender", () => {
|
|
159
|
+
it("registry 就绪时 dispose → registry.unregister + 调 requestRender fn", () => {
|
|
160
|
+
const registry = makeRegistry();
|
|
161
|
+
Reflect.set(globalThis, FOOTER_HANDSHAKE_KEY, {
|
|
162
|
+
version: 1,
|
|
163
|
+
registry,
|
|
164
|
+
pending: [],
|
|
165
|
+
});
|
|
166
|
+
const renderFn = vi.fn();
|
|
167
|
+
Reflect.set(globalThis, REQUEST_RENDER_KEY, renderFn);
|
|
168
|
+
|
|
169
|
+
const dispose = registerPermissionFooterLine(makeRenderer());
|
|
170
|
+
expect(registry.has("pi-permission")).toBe(true);
|
|
171
|
+
dispose();
|
|
172
|
+
expect(registry.has("pi-permission")).toBe(false);
|
|
173
|
+
expect(renderFn).toHaveBeenCalledOnce();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ──────────────────────── TC5:dispose(pending 路径)→ 从 pending 移除 ────────────────────────
|
|
178
|
+
|
|
179
|
+
describe("TC5:dispose(pending 路径)→ 从 pending 移除 + requestFooterRender", () => {
|
|
180
|
+
it("registry 未就绪时 dispose → pending 过滤掉 pi-permission + 调 requestRender fn", () => {
|
|
181
|
+
// 空 slot(pending 路径)
|
|
182
|
+
const renderFn = vi.fn();
|
|
183
|
+
Reflect.set(globalThis, REQUEST_RENDER_KEY, renderFn);
|
|
184
|
+
|
|
185
|
+
const dispose = registerPermissionFooterLine(makeRenderer());
|
|
186
|
+
const slotBefore = getRawSlot() as { pending: { id: string }[] };
|
|
187
|
+
expect(slotBefore.pending).toHaveLength(1);
|
|
188
|
+
|
|
189
|
+
dispose();
|
|
190
|
+
const slotAfter = getRawSlot() as { pending: { id: string }[] };
|
|
191
|
+
expect(slotAfter.pending.some((p) => p.id === "pi-permission")).toBe(false);
|
|
192
|
+
expect(renderFn).toHaveBeenCalledOnce();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ──────────────────────── TC6/TC7:requestFooterRender ────────────────────────
|
|
197
|
+
|
|
198
|
+
describe("TC6/TC7:requestFooterRender", () => {
|
|
199
|
+
it("TC6:statusline 未安装(无 REQUEST_RENDER_KEY)→ noop,不抛异常", () => {
|
|
200
|
+
expect(() => requestFooterRender()).not.toThrow();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("TC7:已注册 fn → 调用 fn", () => {
|
|
204
|
+
const fn = vi.fn();
|
|
205
|
+
Reflect.set(globalThis, REQUEST_RENDER_KEY, fn);
|
|
206
|
+
requestFooterRender();
|
|
207
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ──────────────────────── TC8:version mismatch → 重建 slot ────────────────────────
|
|
212
|
+
|
|
213
|
+
describe("TC8:version mismatch → readSlot 丢弃 + ensureSlot 重建", () => {
|
|
214
|
+
it("slot.version !== 1 → 注册时建新 slot(丢弃旧的),并 console.warn", () => {
|
|
215
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
216
|
+
// 污染槽位:version=99
|
|
217
|
+
Reflect.set(globalThis, FOOTER_HANDSHAKE_KEY, {
|
|
218
|
+
version: 99,
|
|
219
|
+
pending: [],
|
|
220
|
+
});
|
|
221
|
+
registerPermissionFooterLine(makeRenderer());
|
|
222
|
+
const slot = getRawSlot() as { version: number; pending: unknown[] };
|
|
223
|
+
expect(slot.version).toBe(1); // 重建为 version=1
|
|
224
|
+
expect(slot.pending).toHaveLength(1);
|
|
225
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
226
|
+
expect.stringContaining("version mismatch"),
|
|
227
|
+
);
|
|
228
|
+
warnSpy.mockRestore();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("pending 非 Array → 视为无 slot,重建", () => {
|
|
232
|
+
Reflect.set(globalThis, FOOTER_HANDSHAKE_KEY, {
|
|
233
|
+
version: 1,
|
|
234
|
+
pending: "not-an-array",
|
|
235
|
+
});
|
|
236
|
+
registerPermissionFooterLine(makeRenderer());
|
|
237
|
+
const slot = getRawSlot() as { pending: unknown[] };
|
|
238
|
+
expect(Array.isArray(slot.pending)).toBe(true);
|
|
239
|
+
expect(slot.pending).toHaveLength(1);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ──────────────────────── TC13:dispose 幂等 ────────────────────────
|
|
244
|
+
|
|
245
|
+
describe("TC13:dispose 幂等(重复调用安全)", () => {
|
|
246
|
+
it("dispose 两次 → 第二次 noop(once-guard:不重复 unregister / requestRender)", () => {
|
|
247
|
+
const registry = makeRegistry();
|
|
248
|
+
Reflect.set(globalThis, FOOTER_HANDSHAKE_KEY, {
|
|
249
|
+
version: 1,
|
|
250
|
+
registry,
|
|
251
|
+
pending: [],
|
|
252
|
+
});
|
|
253
|
+
const renderFn = vi.fn();
|
|
254
|
+
Reflect.set(globalThis, REQUEST_RENDER_KEY, renderFn);
|
|
255
|
+
|
|
256
|
+
const dispose = registerPermissionFooterLine(makeRenderer());
|
|
257
|
+
dispose();
|
|
258
|
+
// 第二次:once-guard 生效,不应再触发副作用
|
|
259
|
+
expect(() => dispose()).not.toThrow();
|
|
260
|
+
expect(registry.calls.filter((c) => c.op === "unregister")).toHaveLength(1);
|
|
261
|
+
expect(renderFn).toHaveBeenCalledOnce();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("dispose 后 slot 整个被删 → 再次 dispose 不抛(once-guard)", () => {
|
|
265
|
+
const dispose = registerPermissionFooterLine(makeRenderer());
|
|
266
|
+
Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
267
|
+
expect(() => dispose()).not.toThrow();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ──────────────────────── renderPermissionFooterLine 渲染 ────────────────────────
|
|
272
|
+
|
|
273
|
+
describe("renderPermissionFooterLine(mode + enabled + rule count + classifier)", () => {
|
|
274
|
+
const ALL_MODES: PermissionMode[] = ["yolo", "auto", "approve", "strict"];
|
|
275
|
+
|
|
276
|
+
it("enabled=true 非 auto → '[permission] <LABEL> · enabled · N user rules'", () => {
|
|
277
|
+
const palette = passthroughPalette();
|
|
278
|
+
for (const mode of ALL_MODES.filter((m) => m !== "auto")) {
|
|
279
|
+
const line = renderPermissionFooterLine(mode, true, 3, "gpt-4", palette);
|
|
280
|
+
expect(line).toContain("[permission]");
|
|
281
|
+
expect(line).toContain(MODE_LABELS[mode]);
|
|
282
|
+
expect(line).toContain("enabled");
|
|
283
|
+
expect(line).toContain("3 user rules");
|
|
284
|
+
// 非 auto 模式不显示 classifier
|
|
285
|
+
expect(line).not.toContain("classifier");
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("enabled=true + auto 模式 + 有 model → 含 classifier model", () => {
|
|
290
|
+
const palette = passthroughPalette();
|
|
291
|
+
const line = renderPermissionFooterLine("auto", true, 2, "zhipu/glm-4-flash", palette);
|
|
292
|
+
expect(line).toContain("[permission]");
|
|
293
|
+
expect(line).toContain(MODE_LABELS["auto"]);
|
|
294
|
+
expect(line).toContain("enabled");
|
|
295
|
+
expect(line).toContain("2 user rules");
|
|
296
|
+
expect(line).toContain("classifier: zhipu/glm-4-flash");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("enabled=true + auto 模式 + model 为空 → 不显示 classifier 段", () => {
|
|
300
|
+
const palette = passthroughPalette();
|
|
301
|
+
const line = renderPermissionFooterLine("auto", true, 0, "", palette);
|
|
302
|
+
expect(line).toContain("0 user rules");
|
|
303
|
+
expect(line).not.toContain("classifier");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("enabled=true + ruleCount=1 → 'rule'(单数)", () => {
|
|
307
|
+
const palette = passthroughPalette();
|
|
308
|
+
const line = renderPermissionFooterLine("strict", true, 1, "", palette);
|
|
309
|
+
expect(line).toContain("1 user rule");
|
|
310
|
+
expect(line).not.toContain("1 user rules");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("enabled=false → '[permission] disabled'(不含 LABEL/rule/classifier)", () => {
|
|
314
|
+
const palette = passthroughPalette();
|
|
315
|
+
for (const mode of ALL_MODES) {
|
|
316
|
+
const line = renderPermissionFooterLine(mode, false, 3, "gpt-4", palette);
|
|
317
|
+
expect(line).toContain("[permission]");
|
|
318
|
+
expect(line).toContain("disabled");
|
|
319
|
+
// disabled 时精简:不显示 mode label / rule count / classifier
|
|
320
|
+
expect(line).not.toContain(MODE_LABELS[mode]);
|
|
321
|
+
expect(line).not.toContain("rule");
|
|
322
|
+
expect(line).not.toContain("classifier");
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("返回单行字符串(非数组)", () => {
|
|
327
|
+
const line = renderPermissionFooterLine("auto", true, 1, "gpt-4", passthroughPalette());
|
|
328
|
+
expect(typeof line).toBe("string");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ──────────────────────── 字面量跨端一致(与 statusline owner 端 === ) ────────────────────────
|
|
333
|
+
|
|
334
|
+
describe("协议字面量跨端一致(permission consumer === statusline owner)", () => {
|
|
335
|
+
it("FOOTER_HANDSHAKE_KEY 是 Symbol.for 同一字符串(Symbol.for 跨 realm 同引用)", () => {
|
|
336
|
+
expect(FOOTER_HANDSHAKE_KEY).toBe(Symbol.for("@zhushanwen/pi-statusline.footerHandshake"));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("REQUEST_RENDER_KEY 是 Symbol.for 同一字符串", () => {
|
|
340
|
+
expect(REQUEST_RENDER_KEY).toBe(Symbol.for("@zhushanwen/pi-statusline.requestRender"));
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -19,6 +19,7 @@ import { join } from "node:path";
|
|
|
19
19
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
21
21
|
|
|
22
|
+
import { FOOTER_HANDSHAKE_KEY, REQUEST_RENDER_KEY } from "../footer-provider.js";
|
|
22
23
|
import permissionExtension from "../index.js";
|
|
23
24
|
|
|
24
25
|
// ──────────────────────── mock pi ────────────────────────
|
|
@@ -73,6 +74,15 @@ function getSessionStartHandler(calls: MockPiCalls): (event: unknown, ctx: unkno
|
|
|
73
74
|
return handlers[0];
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/** 提取 session_tree handler。 */
|
|
78
|
+
function getSessionTreeHandler(calls: MockPiCalls): (event: unknown, ctx: unknown) => unknown {
|
|
79
|
+
const handlers = calls.eventHandlers.get("session_tree");
|
|
80
|
+
if (!handlers || handlers.length === 0) {
|
|
81
|
+
throw new Error("session_tree handler not registered");
|
|
82
|
+
}
|
|
83
|
+
return handlers[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
/** 构造 mock ctx(含 mode/ui/signal)。 */
|
|
77
87
|
function makeCtx(mode: "tui" | "rpc" | "json" | "print" = "json"): unknown {
|
|
78
88
|
return {
|
|
@@ -125,6 +135,9 @@ beforeEach(() => {
|
|
|
125
135
|
afterEach(() => {
|
|
126
136
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
127
137
|
if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
138
|
+
// 清理 footer 握手 slot(session_start/session_tree handler 会写 globalThis)
|
|
139
|
+
Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
140
|
+
Reflect.deleteProperty(globalThis, REQUEST_RENDER_KEY);
|
|
128
141
|
});
|
|
129
142
|
|
|
130
143
|
// ──────────────────────── 测试 ────────────────────────
|
|
@@ -280,3 +293,92 @@ describe("W8 /permission rule 命令集成", () => {
|
|
|
280
293
|
await expect(handler("rule", ctx)).resolves.not.toThrow();
|
|
281
294
|
});
|
|
282
295
|
});
|
|
296
|
+
|
|
297
|
+
// ──────────────────────── footer line 注册(duck typing + session_tree)────────────────────────
|
|
298
|
+
|
|
299
|
+
describe("footer line 注册", () => {
|
|
300
|
+
it("session_start:ctx.ui 无 theme → registerFooterLineFor 返回 noop dispose,不抛异常", () => {
|
|
301
|
+
writeConfig("yolo");
|
|
302
|
+
const { pi, calls } = createMockPi();
|
|
303
|
+
permissionExtension(pi);
|
|
304
|
+
const sessionHandler = getSessionStartHandler(calls);
|
|
305
|
+
|
|
306
|
+
// ctx.ui 完全无 theme 字段(headless/mock 形状)。
|
|
307
|
+
// registerFooterLineFor 的 duck typing 守卫应命中,返回 () => {} 而不调
|
|
308
|
+
// registerPermissionFooterLine(故 globalThis 不应出现握手 slot)。
|
|
309
|
+
const ctxNoTheme = { mode: "json", cwd: "/tmp", ui: {}, signal: undefined };
|
|
310
|
+
expect(() => sessionHandler({}, ctxNoTheme)).not.toThrow();
|
|
311
|
+
expect(Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY)).toBeUndefined();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("session_start:ctx.ui.theme 存在但 fg 非函数 → noop dispose,不抛异常", () => {
|
|
315
|
+
writeConfig("yolo");
|
|
316
|
+
const { pi, calls } = createMockPi();
|
|
317
|
+
permissionExtension(pi);
|
|
318
|
+
const sessionHandler = getSessionStartHandler(calls);
|
|
319
|
+
|
|
320
|
+
// theme 存在但 fg 缺失/非函数 → duck typing 失败 → noop dispose。
|
|
321
|
+
const ctxBadFg = {
|
|
322
|
+
mode: "json",
|
|
323
|
+
cwd: "/tmp",
|
|
324
|
+
ui: { theme: { fg: "not-a-function" } },
|
|
325
|
+
signal: undefined,
|
|
326
|
+
};
|
|
327
|
+
expect(() => sessionHandler({}, ctxBadFg)).not.toThrow();
|
|
328
|
+
// 未注册 renderer → 不应触碰 globalThis 握手 slot
|
|
329
|
+
expect(Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY)).toBeUndefined();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("session_tree:触发两次 handler 不抛异常(dispose 旧 renderer → refreshConfig → 重注册)", () => {
|
|
333
|
+
writeConfig("yolo");
|
|
334
|
+
const { pi, calls } = createMockPi();
|
|
335
|
+
permissionExtension(pi);
|
|
336
|
+
const sessionTreeHandler = getSessionTreeHandler(calls);
|
|
337
|
+
|
|
338
|
+
// ctx.ui.theme 为合法 fg 函数 → 真实注册 renderer(写 globalThis slot)。
|
|
339
|
+
const ctxWithTheme = {
|
|
340
|
+
mode: "json",
|
|
341
|
+
cwd: "/tmp",
|
|
342
|
+
ui: {
|
|
343
|
+
theme: {
|
|
344
|
+
fg: (_token: string, text: string): string => text,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
signal: undefined,
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// 第一次:注册 renderer(无旧 renderer,dispose 是 noop)。
|
|
351
|
+
expect(() => sessionTreeHandler({}, ctxWithTheme)).not.toThrow();
|
|
352
|
+
expect(Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY)).toBeDefined();
|
|
353
|
+
|
|
354
|
+
// 切换 config(模拟分支切换后 permission-config.json 变化)。
|
|
355
|
+
writeConfig("strict");
|
|
356
|
+
|
|
357
|
+
// 第二次:dispose 上次的 renderer → refreshConfig 读到 strict → 重新注册。
|
|
358
|
+
// disposeFooterLine 是闭包私有变量,这里间接验证:多次触发不抛、不破坏 slot 结构。
|
|
359
|
+
expect(() => sessionTreeHandler({}, ctxWithTheme)).not.toThrow();
|
|
360
|
+
// slot 仍存在且结构合规(version=1,pending 是数组)
|
|
361
|
+
const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY) as {
|
|
362
|
+
version: number;
|
|
363
|
+
pending: unknown[];
|
|
364
|
+
};
|
|
365
|
+
expect(slot).toBeDefined();
|
|
366
|
+
expect(slot.version).toBe(1);
|
|
367
|
+
expect(Array.isArray(slot.pending)).toBe(true);
|
|
368
|
+
// 两次注册 + 一次 dispose(第二次触发前)→ pending 应至多 1 项(旧 renderer 已 dispose 移除)
|
|
369
|
+
expect(slot.pending.length).toBeLessThanOrEqual(1);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("session_tree:ctx.ui 无 theme 时多次触发仍安全(noop dispose 重复调用)", () => {
|
|
373
|
+
writeConfig("yolo");
|
|
374
|
+
const { pi, calls } = createMockPi();
|
|
375
|
+
permissionExtension(pi);
|
|
376
|
+
const sessionTreeHandler = getSessionTreeHandler(calls);
|
|
377
|
+
|
|
378
|
+
// 无 theme → 两次都是 noop dispose,不应抛、不应写 globalThis。
|
|
379
|
+
const ctxNoTheme = { mode: "json", cwd: "/tmp", ui: {}, signal: undefined };
|
|
380
|
+
expect(() => sessionTreeHandler({}, ctxNoTheme)).not.toThrow();
|
|
381
|
+
expect(() => sessionTreeHandler({}, ctxNoTheme)).not.toThrow();
|
|
382
|
+
expect(Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY)).toBeUndefined();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* statusline-palette.test.ts — paletteFromTheme 适配函数单测。
|
|
3
|
+
*
|
|
4
|
+
* paletteFromTheme 是纯适配函数:把 Pi Theme 的 theme.fg(token, text) 语义着色 API
|
|
5
|
+
* 包装为 PermissionPalette 的 5 个字符串着色器(dim/text/accent/success/warning)。
|
|
6
|
+
* 本测试记录 fg 调用参数,断言每个着色器转发正确的 token + 原始字符串,
|
|
7
|
+
* 避免后续重构拼错 token(如 "succes")时静默丢失色彩。
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { paletteFromTheme } from "../statusline-palette.js";
|
|
12
|
+
|
|
13
|
+
/** 构造 mock theme:fg 是 spy,记录 (token, text) 调用并原样返回 text。 */
|
|
14
|
+
function makeMockTheme(): {
|
|
15
|
+
theme: { fg: (token: string, text: string) => string };
|
|
16
|
+
fg: ReturnType<typeof vi.fn>;
|
|
17
|
+
} {
|
|
18
|
+
const fg = vi.fn((_token: string, text: string): string => text);
|
|
19
|
+
return { theme: { fg }, fg };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("paletteFromTheme", () => {
|
|
23
|
+
it("dim → theme.fg('dim', s)", () => {
|
|
24
|
+
const { theme, fg } = makeMockTheme();
|
|
25
|
+
const palette = paletteFromTheme(theme);
|
|
26
|
+
const result = palette.dim("muted text");
|
|
27
|
+
expect(result).toBe("muted text");
|
|
28
|
+
expect(fg).toHaveBeenCalledWith("dim", "muted text");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("text → theme.fg('text', s)", () => {
|
|
32
|
+
const { theme, fg } = makeMockTheme();
|
|
33
|
+
const palette = paletteFromTheme(theme);
|
|
34
|
+
const result = palette.text("main body");
|
|
35
|
+
expect(result).toBe("main body");
|
|
36
|
+
expect(fg).toHaveBeenCalledWith("text", "main body");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accent → theme.fg('accent', s)", () => {
|
|
40
|
+
const { theme, fg } = makeMockTheme();
|
|
41
|
+
const palette = paletteFromTheme(theme);
|
|
42
|
+
const result = palette.accent("Auto");
|
|
43
|
+
expect(result).toBe("Auto");
|
|
44
|
+
expect(fg).toHaveBeenCalledWith("accent", "Auto");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("success → theme.fg('success', s)", () => {
|
|
48
|
+
const { theme, fg } = makeMockTheme();
|
|
49
|
+
const palette = paletteFromTheme(theme);
|
|
50
|
+
const result = palette.success("enabled");
|
|
51
|
+
expect(result).toBe("enabled");
|
|
52
|
+
// 关键断言:token 必须是完整的 "success"(防止拼错成 "succes")
|
|
53
|
+
expect(fg).toHaveBeenCalledWith("success", "enabled");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("warning → theme.fg('warning', s)", () => {
|
|
57
|
+
const { theme, fg } = makeMockTheme();
|
|
58
|
+
const palette = paletteFromTheme(theme);
|
|
59
|
+
const result = palette.warning("disabled");
|
|
60
|
+
expect(result).toBe("disabled");
|
|
61
|
+
expect(fg).toHaveBeenCalledWith("warning", "disabled");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("五个着色器各自转发正确的 token(全量校验,防 token 漂移)", () => {
|
|
65
|
+
const { theme, fg } = makeMockTheme();
|
|
66
|
+
const palette = paletteFromTheme(theme);
|
|
67
|
+
palette.dim("a");
|
|
68
|
+
palette.text("b");
|
|
69
|
+
palette.accent("c");
|
|
70
|
+
palette.success("d");
|
|
71
|
+
palette.warning("e");
|
|
72
|
+
expect(fg).toHaveBeenNthCalledWith(1, "dim", "a");
|
|
73
|
+
expect(fg).toHaveBeenNthCalledWith(2, "text", "b");
|
|
74
|
+
expect(fg).toHaveBeenNthCalledWith(3, "accent", "c");
|
|
75
|
+
expect(fg).toHaveBeenNthCalledWith(4, "success", "d");
|
|
76
|
+
expect(fg).toHaveBeenNthCalledWith(5, "warning", "e");
|
|
77
|
+
expect(fg).toHaveBeenCalledTimes(5);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("返回值是 theme.fg 的返回值(透传,不做额外加工)", () => {
|
|
81
|
+
const theme = {
|
|
82
|
+
fg: (token: string, text: string): string => `<${token}>${text}</${token}>`,
|
|
83
|
+
};
|
|
84
|
+
const palette = paletteFromTheme(theme);
|
|
85
|
+
expect(palette.dim("x")).toBe("<dim>x</dim>");
|
|
86
|
+
expect(palette.success("y")).toBe("<success>y</success>");
|
|
87
|
+
});
|
|
88
|
+
});
|