@zhushanwen/pi-permission 0.0.1
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 +455 -0
- package/index.ts +2 -0
- package/package.json +52 -0
- package/src/__tests__/approval.test.ts +456 -0
- package/src/__tests__/commands.test.ts +487 -0
- package/src/__tests__/config.test.ts +229 -0
- package/src/__tests__/e2e-modes.test.ts +253 -0
- package/src/__tests__/extension.test.ts +170 -0
- package/src/__tests__/index-integration.test.ts +282 -0
- package/src/__tests__/model-picker.test.ts +371 -0
- package/src/__tests__/pipeline.test.ts +976 -0
- package/src/__tests__/production.test.ts +102 -0
- package/src/__tests__/rule-editor-component.test.ts +475 -0
- package/src/__tests__/rule-editor.test.ts +252 -0
- package/src/__tests__/rule-templates.test.ts +264 -0
- package/src/__tests__/statusline.test.ts +221 -0
- package/src/__tests__/types.test.ts +59 -0
- package/src/approval.ts +352 -0
- package/src/ast/__tests__/analyzer.test.ts +262 -0
- package/src/ast/__tests__/loader.test.ts +136 -0
- package/src/ast/analyzer.ts +326 -0
- package/src/ast/index.ts +7 -0
- package/src/ast/loader.ts +113 -0
- package/src/classifier/__tests__/classifier.test.ts +285 -0
- package/src/classifier/__tests__/json-parser.test.ts +118 -0
- package/src/classifier/__tests__/model-resolver.test.ts +173 -0
- package/src/classifier/__tests__/prompt.test.ts +59 -0
- package/src/classifier/classifier.ts +242 -0
- package/src/classifier/index.ts +28 -0
- package/src/classifier/json-parser.ts +127 -0
- package/src/classifier/model-resolver.ts +295 -0
- package/src/classifier/prompt.ts +52 -0
- package/src/commands.ts +234 -0
- package/src/config.ts +216 -0
- package/src/index.ts +291 -0
- package/src/model-picker.ts +398 -0
- package/src/pipeline.ts +541 -0
- package/src/production.ts +102 -0
- package/src/rule-editor-component.ts +883 -0
- package/src/rule-editor.ts +262 -0
- package/src/rule-templates.ts +332 -0
- package/src/rules/__tests__/builtins.test.ts +614 -0
- package/src/rules/__tests__/index.test.ts +26 -0
- package/src/rules/__tests__/matcher.test.ts +378 -0
- package/src/rules/__tests__/wildcard.test.ts +84 -0
- package/src/rules/builtins.ts +680 -0
- package/src/rules/index.ts +27 -0
- package/src/rules/matcher.ts +160 -0
- package/src/rules/wildcard.ts +43 -0
- package/src/statusline.ts +226 -0
- package/src/types.ts +249 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zhushanwen/pi-permission — Pi permission 扩展
|
|
3
|
+
*
|
|
4
|
+
* 四档权限模式(yolo/auto/approve/strict)+ 三层管道(AST + 规则 + AI Classifier)。
|
|
5
|
+
*
|
|
6
|
+
* W5 阶段:tool_call handler 接入三层管道(checkPermission)。
|
|
7
|
+
* - G5:显式 approvalChain promise chain 串行化(Pi 不保证 tool_call handler 串行,
|
|
8
|
+
* 但权限检查涉及共享状态/UI 对话框,必须串行避免竞态)。
|
|
9
|
+
* - fail-closed:handler 异常 → block + reason(不放行)。
|
|
10
|
+
* - session 隔离:config 在 session_start 重建的闭包,每 session 独立。
|
|
11
|
+
* - yolo 快速路径:mode=yolo 或 enabled=false → 直接 return undefined(不跑管道)。
|
|
12
|
+
*
|
|
13
|
+
* W6 阶段:session_start 注册权限 footer(ctx.ui.setFooter),显示当前 mode + enabled。
|
|
14
|
+
* - 单例限制:Pi 只有一个 footer 槽位,会覆盖其他扩展的 footer(README 注明)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
ExtensionAPI,
|
|
19
|
+
ExtensionCommandContext,
|
|
20
|
+
ExtensionContext,
|
|
21
|
+
} from "@earendil-works/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
import { listAvailableModels } from "./classifier/model-resolver.js";
|
|
24
|
+
import { handlePermissionCommand, handlePermissionModelCommand, handlePermissionRuleCommand } from "./commands.js";
|
|
25
|
+
import { getConfigPath, loadAndWatchConfig, saveConfig } from "./config.js";
|
|
26
|
+
import { setDefaultListAvailableModels } from "./model-picker.js";
|
|
27
|
+
import { editRulesViaOverlay } from "./rule-editor.js";
|
|
28
|
+
import { makeNextIdCounter } from "./rule-templates.js";
|
|
29
|
+
import { checkPermission, type CheckPermissionDeps } from "./pipeline.js";
|
|
30
|
+
import { createPipelineDeps } from "./production.js";
|
|
31
|
+
import { registerPermissionFooter } from "./statusline.js";
|
|
32
|
+
import type { PermissionConfig } from "./types.js";
|
|
33
|
+
|
|
34
|
+
// ──────────────────────── tool_call event 最小子集 ────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pi tool_call event 的最小子集(duck typing,不依赖完整 SDK 类型)。
|
|
38
|
+
*
|
|
39
|
+
* event.type='tool_call',event.toolCallId,event.toolName,event.input。
|
|
40
|
+
* bash input={command, timeout?},其他工具 input={path, ...}。
|
|
41
|
+
*/
|
|
42
|
+
interface ToolCallEventLike {
|
|
43
|
+
toolName: string;
|
|
44
|
+
input: Record<string, unknown>;
|
|
45
|
+
toolCallId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pi tool_call handler 返回值:{block:true, reason} → Pi 转 isError tool_result;undefined → 放行。 */
|
|
49
|
+
interface ToolCallResult {
|
|
50
|
+
block?: boolean;
|
|
51
|
+
reason?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ──────────────────────── 扩展工厂 ────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 扩展工厂。每个 session 独立闭包状态(遵循 Pi session 隔离约束,
|
|
58
|
+
* 不用模块级 let 避免多 session 共享)。
|
|
59
|
+
*/
|
|
60
|
+
export default function permissionExtension(pi: ExtensionAPI): void {
|
|
61
|
+
// ──────────────────────── 闭包状态(每 session 独立) ────────────────────────
|
|
62
|
+
let config: PermissionConfig = loadAndWatchConfig(getConfigPath(), (msg) => {
|
|
63
|
+
console.warn(msg);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// W7:注入 listAvailableModels 真实实现(model-picker.ts 默认返回空 Map)。
|
|
67
|
+
// G1 口径:(onWarning?, filePath?) → 封装读盘;warning 透传到 console.warn。
|
|
68
|
+
setDefaultListAvailableModels((onWarning, filePath) =>
|
|
69
|
+
listAvailableModels(onWarning ?? ((m) => console.warn(m)), filePath),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/** 读取最新配置到闭包变量(mtime 缓存内部去重,未变化不读 fs) */
|
|
73
|
+
function refreshConfig(): void {
|
|
74
|
+
config = loadAndWatchConfig(getConfigPath(), (msg) => console.warn(msg));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// W6 T2:footer invalidator。session_start 注册 footer 时由 registerPermissionFooter
|
|
78
|
+
// 返回(headless/mock 返回 noop)。mode/enabled 切换成功后调用,使 footer 立即重绘
|
|
79
|
+
// 新标签(FooterHandle.render 有缓存,不 invalidate 会显示旧 mode 直到 resize)。
|
|
80
|
+
let invalidateFooter: () => void = () => {};
|
|
81
|
+
|
|
82
|
+
// ──────────────────────── session_start:重载配置 + 注册 footer ────────────────────────
|
|
83
|
+
pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
|
|
84
|
+
refreshConfig();
|
|
85
|
+
// W6 T2:注册权限 footer(显示当前 mode + enabled)。
|
|
86
|
+
// 单例限制:Pi 只有一个 footer 槽位,会覆盖(或被覆盖)其他扩展的 footer
|
|
87
|
+
// (如 @zhushanwen/pi-statusline)。已知限制,README 注明。
|
|
88
|
+
// getMode/getEnabled 闭包读最新 config(session 内 mode 可变)。
|
|
89
|
+
// registerPermissionFooter 内部 duck typing:headless/mock ctx 无 setFooter/theme 时
|
|
90
|
+
// 跳过,返回 noop invalidator。
|
|
91
|
+
invalidateFooter = registerPermissionFooter(
|
|
92
|
+
ctx.ui as { setFooter?: unknown; theme?: unknown },
|
|
93
|
+
() => config.mode,
|
|
94
|
+
() => config.enabled,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ──────────────────────── /permission 命令 ────────────────────────
|
|
99
|
+
pi.registerCommand("permission", {
|
|
100
|
+
description: "View or switch permission mode. Usage: /permission [mode|status|rule|model]",
|
|
101
|
+
handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
102
|
+
// 命令执行前重载配置(确保最新,用户可能手动改过文件)
|
|
103
|
+
refreshConfig();
|
|
104
|
+
const trimmed = (args ?? "").trim();
|
|
105
|
+
// W8:/permission rule → overlay CRUD 编辑 userRules(异步路径)
|
|
106
|
+
if (trimmed === "rule") {
|
|
107
|
+
await handlePermissionRuleCommand(
|
|
108
|
+
{
|
|
109
|
+
mode: ctx.mode,
|
|
110
|
+
ui: {
|
|
111
|
+
notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
|
|
112
|
+
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[2]) =>
|
|
113
|
+
ctx.ui.select(title, options, opts),
|
|
114
|
+
custom: <T,>(
|
|
115
|
+
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
116
|
+
options?: { overlay?: boolean },
|
|
117
|
+
) =>
|
|
118
|
+
ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
|
|
119
|
+
// 连接 ctx.ui.input(rule-editor custom 模板文本输入用)。
|
|
120
|
+
// approval.ts 已声明可选 input(SDK 提供,mock 可能缺失)。
|
|
121
|
+
...(typeof ctx.ui.input === "function"
|
|
122
|
+
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[2]) => ctx.ui.input(title, placeholder, opts) }
|
|
123
|
+
: {}),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
config,
|
|
127
|
+
makeNextIdCounter(config.userRules),
|
|
128
|
+
{
|
|
129
|
+
save: (newConfig) => {
|
|
130
|
+
const result = saveConfig(newConfig);
|
|
131
|
+
if (result.success) {
|
|
132
|
+
config = newConfig; // 更新闭包状态
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
},
|
|
136
|
+
editRulesViaOverlay: (ctx, initialRules, sessionIdCounter, rpcDeps) =>
|
|
137
|
+
editRulesViaOverlay(ctx, initialRules, sessionIdCounter, rpcDeps),
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// W7:/permission model → overlay 选择 classifier model(异步路径)
|
|
143
|
+
if (trimmed === "model") {
|
|
144
|
+
await handlePermissionModelCommand(
|
|
145
|
+
{
|
|
146
|
+
mode: ctx.mode,
|
|
147
|
+
ui: {
|
|
148
|
+
notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
|
|
149
|
+
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[2]) =>
|
|
150
|
+
ctx.ui.select(title, options, opts),
|
|
151
|
+
custom: <T,>(
|
|
152
|
+
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
153
|
+
options?: { overlay?: boolean },
|
|
154
|
+
) =>
|
|
155
|
+
ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
|
|
156
|
+
// 连接 ctx.ui.input(与 rule handler 一致;model picker 当前不用,但保持 ctx 对称)。
|
|
157
|
+
...(typeof ctx.ui.input === "function"
|
|
158
|
+
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[2]) => ctx.ui.input(title, placeholder, opts) }
|
|
159
|
+
: {}),
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
config,
|
|
163
|
+
{
|
|
164
|
+
listModels: () => listAvailableModels((m) => console.warn(m)),
|
|
165
|
+
save: (newConfig) => {
|
|
166
|
+
const result = saveConfig(newConfig);
|
|
167
|
+
if (result.success) {
|
|
168
|
+
config = newConfig; // 更新闭包状态
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// 原同步路径(yolo/auto/approve/strict/status/无参)
|
|
177
|
+
const message = handlePermissionCommand(args, config, (newConfig) => {
|
|
178
|
+
const result = saveConfig(newConfig);
|
|
179
|
+
if (result.success) {
|
|
180
|
+
config = newConfig; // 更新闭包状态
|
|
181
|
+
// footer 仅显示 mode + enabled;切换成功后刷缓存 + 重绘,避免显示旧 mode。
|
|
182
|
+
invalidateFooter();
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
});
|
|
186
|
+
ctx.ui.notify(message, "info");
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ──────────────────────── tool_call handler(W5 三层管道 + G5 串行化) ────────────────────────
|
|
191
|
+
// G5:显式 approvalChain promise chain。Pi 不保证 tool_call handler 串行调用,
|
|
192
|
+
// 但权限检查可能弹出 UI 对话框(共享终端),必须串行避免多个对话框叠加。
|
|
193
|
+
let approvalChain: Promise<ToolCallResult | undefined> = Promise.resolve(undefined);
|
|
194
|
+
|
|
195
|
+
pi.on("tool_call", (event: unknown, ctx: ExtensionContext): Promise<ToolCallResult | undefined> => {
|
|
196
|
+
const run = (): Promise<ToolCallResult | undefined> => processToolCall(event, ctx, () => config, refreshConfig);
|
|
197
|
+
// 串行:前一个完成(无论 resolve/reject)后才跑下一个。失败不影响后续。
|
|
198
|
+
approvalChain = approvalChain.then(run, run);
|
|
199
|
+
return approvalChain;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ──────────────────────── processToolCall(单次工具调用处理) ────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 处理单次 tool_call:提 config → checkPermission → 映射为 Pi ToolCallResult。
|
|
207
|
+
*
|
|
208
|
+
* fail-closed:任何异常 → block + reason(不放行)。
|
|
209
|
+
* yolo 快速路径:mode=yolo 或 enabled=false → return undefined(不跑管道,最小开销)。
|
|
210
|
+
*
|
|
211
|
+
* @param event tool_call event(duck typing 为 ToolCallEventLike)
|
|
212
|
+
* @param ctx Pi ExtensionContext
|
|
213
|
+
* @param getConfig 获取最新 config 的闭包(session 隔离)
|
|
214
|
+
* @param _refreshConfig 重载配置(保留参数位,便于未来扩展)
|
|
215
|
+
*/
|
|
216
|
+
async function processToolCall(
|
|
217
|
+
event: unknown,
|
|
218
|
+
ctx: ExtensionContext,
|
|
219
|
+
getConfig: () => PermissionConfig,
|
|
220
|
+
_refreshConfig: () => void,
|
|
221
|
+
): Promise<ToolCallResult | undefined> {
|
|
222
|
+
const cfg = getConfig();
|
|
223
|
+
|
|
224
|
+
// 快速路径:yolo 或 disabled → 完全放行(不跑管道,最小开销)
|
|
225
|
+
if (cfg.mode === "yolo" || !cfg.enabled) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 提取 event 字段(duck typing,防御非预期形状)
|
|
230
|
+
const evt = event as ToolCallEventLike;
|
|
231
|
+
const toolName = typeof evt?.toolName === "string" ? evt.toolName : "";
|
|
232
|
+
const input = evt?.input !== null && typeof evt?.input === "object" && !Array.isArray(evt.input)
|
|
233
|
+
? (evt.input as Record<string, unknown>)
|
|
234
|
+
: {};
|
|
235
|
+
|
|
236
|
+
if (toolName.length === 0) {
|
|
237
|
+
// 无法识别工具名 → fail-closed block
|
|
238
|
+
return { block: true, reason: "[pi-permission] tool_call event missing toolName" };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 装配 deps(每次 tool_call 重新装配,捕获当前 ctx.mode/ui;classifier 单例在 createPipelineDeps 内)
|
|
242
|
+
const approvalCtx = {
|
|
243
|
+
mode: ctx.mode,
|
|
244
|
+
ui: {
|
|
245
|
+
notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
|
|
246
|
+
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[2]) => ctx.ui.select(title, options, opts),
|
|
247
|
+
custom: <T,>(
|
|
248
|
+
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
249
|
+
options?: { overlay?: boolean },
|
|
250
|
+
) =>
|
|
251
|
+
ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
|
|
252
|
+
// W6 T9 G3:Reject-with-Reason。ctx.ui.input 存在则透传(采集真实拒绝理由)。
|
|
253
|
+
// approval.ts 的 collectRejectReason 会用 typeof 判断是否可用,不可用则 fallback。
|
|
254
|
+
...(typeof ctx.ui.input === "function"
|
|
255
|
+
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[2]) => ctx.ui.input(title, placeholder, opts) }
|
|
256
|
+
: {}),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
const deps: CheckPermissionDeps = createPipelineDeps(approvalCtx);
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const decision = await checkPermission(
|
|
263
|
+
toolName,
|
|
264
|
+
input,
|
|
265
|
+
cfg.mode,
|
|
266
|
+
cfg.classifier,
|
|
267
|
+
cfg.userRules,
|
|
268
|
+
deps,
|
|
269
|
+
{ cwd: ctx.cwd, signal: ctx.signal },
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
if (decision.action === "allow") {
|
|
273
|
+
// 放行:return undefined(Pi 不拦截)
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
// deny / ask → block + reason(Pi 转 isError tool_result)
|
|
277
|
+
// ask 在 checkPermission 内已转 user 决策;若到这仍是 ask,fail-closed 当 deny
|
|
278
|
+
return {
|
|
279
|
+
block: true,
|
|
280
|
+
reason: `[pi-permission] ${decision.action}: ${decision.reason} (source=${decision.source})`,
|
|
281
|
+
};
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// fail-closed:异常 → block + reason(绝不放行)
|
|
284
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
285
|
+
console.warn(`[pi-permission] tool_call handler exception for ${toolName}: ${msg}`);
|
|
286
|
+
return {
|
|
287
|
+
block: true,
|
|
288
|
+
reason: `[pi-permission] internal error (fail-closed): ${msg}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W7:/permission model overlay 选择器。
|
|
3
|
+
*
|
|
4
|
+
* 两级 TUI 选择:
|
|
5
|
+
* - 第一级(provider stage):列出 'Auto' + 所有可用 provider(listAvailableModels)
|
|
6
|
+
* - 第二级(model stage):选中具体 provider 后,列出该 provider 下的 model(按 cost 升序)
|
|
7
|
+
* - 选 'Auto' 或具体 provider/model → done(SelectionResult);Esc 回退 / 取消
|
|
8
|
+
*
|
|
9
|
+
* 三模式分发(pickModelViaOverlay):
|
|
10
|
+
* - tui:ctx.ui.custom(ProviderModelSelectorComponent,overlay)。custom 的 factory 接收
|
|
11
|
+
* done 回调,comp 内部 onSelect/onCancel 最终调 done(参考 approval.ts ApprovalComponent)。
|
|
12
|
+
* - rpc:两次 ctx.ui.select(provider 含 auto,model)
|
|
13
|
+
* - headless(json/print):返回 undefined(无交互 UI,降级)
|
|
14
|
+
*
|
|
15
|
+
* 关键设计:
|
|
16
|
+
* - ProviderModelSelectorComponent extends Container:Container 无 handleInput,必须 override
|
|
17
|
+
* 委托给当前 stage 的 SelectList(WC9/WC15 critical)。
|
|
18
|
+
* - stage 切换用 clear() + addChild 重建(两态状态机)。
|
|
19
|
+
* - _resolved 守卫防二次 done(switchToModelStage 等异步路径可能重复触发)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { type Component, Container, type SelectItem, SelectList, type SelectListTheme, truncateToWidth } from "@earendil-works/pi-tui";
|
|
23
|
+
|
|
24
|
+
import type { ResolvedModelEntry } from "./classifier/model-resolver.js";
|
|
25
|
+
|
|
26
|
+
// ──────────────────────── 类型 ────────────────────────
|
|
27
|
+
|
|
28
|
+
/** 选择结果(discriminated union);undefined = cancel / 降级。 */
|
|
29
|
+
export type SelectionResult =
|
|
30
|
+
| { kind: "auto" }
|
|
31
|
+
| { kind: "specific"; provider: string; modelId: string };
|
|
32
|
+
|
|
33
|
+
// ──────────────────────── DEFAULT_SELECT_THEME(G2 修正) ────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* SelectList 默认主题。
|
|
37
|
+
*
|
|
38
|
+
* G2/WR2 修正:selectedPrefix 实现为 `(t) => '▶ ' + t`(非 identity),
|
|
39
|
+
* 选中行有视觉区分(▶ 前缀)。
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_SELECT_THEME: SelectListTheme = {
|
|
42
|
+
selectedPrefix: (t: string): string => "\u25B6 " + t,
|
|
43
|
+
selectedText: (t: string): string => t,
|
|
44
|
+
description: (t: string): string => t,
|
|
45
|
+
scrollInfo: (t: string): string => t,
|
|
46
|
+
noMatch: (t: string): string => t,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** provider stage 的 'auto' 项 value(用特殊标记区分真实 provider 名)。 */
|
|
50
|
+
const AUTO_VALUE = "__auto__";
|
|
51
|
+
|
|
52
|
+
/** SelectList 最大可见行数(picker overlay 不超过终端高度)。 */
|
|
53
|
+
const MAX_VISIBLE_ITEMS = 10;
|
|
54
|
+
|
|
55
|
+
// ──────────────────────── 辅助组件(inline Component stub,避免依赖 Box 的 padding/bg) ────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 最小 inline Component:渲染固定文本行(title / hint)。
|
|
59
|
+
* Container 不要求 children 有 handleInput,只要 render + invalidate。
|
|
60
|
+
*/
|
|
61
|
+
class TextLines implements Component {
|
|
62
|
+
private readonly lines: string[];
|
|
63
|
+
|
|
64
|
+
constructor(lines: string[]) {
|
|
65
|
+
this.lines = lines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
render(_width: number): string[] {
|
|
69
|
+
return [...this.lines];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
invalidate(): void {
|
|
73
|
+
// 无缓存,no-op
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ──────────────────────── ProviderModelSelectorComponent ────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 两级 TUI 选择组件(provider → model)。
|
|
81
|
+
*
|
|
82
|
+
* 状态机:
|
|
83
|
+
* - stage='provider':SelectList 列出 'Auto' + providers
|
|
84
|
+
* - stage='model':SelectList 列出所选 provider 的 models
|
|
85
|
+
*
|
|
86
|
+
* 切换:clear() + addChild 重建(两态,无回退栈,model stage Esc 回退到 provider stage)。
|
|
87
|
+
*
|
|
88
|
+
* WC9/WC15 critical:Container 没有 handleInput,必须 override 委托给当前 stage 的 SelectList。
|
|
89
|
+
* 测试 WR1:直接调 comp.handleInput('\r') 验证 SelectList.onSelect 被触发(锁定键盘委托通路)。
|
|
90
|
+
*
|
|
91
|
+
* done 桥接:comp 构造时传入 done(由 ctx.ui.custom 的 factory 提供),内部 onSelect/onCancel
|
|
92
|
+
* 最终调 done(参考 ApprovalComponent)。_resolved 守卫防二次 done。
|
|
93
|
+
*/
|
|
94
|
+
export class ProviderModelSelectorComponent extends Container {
|
|
95
|
+
private stage: "provider" | "model" = "provider";
|
|
96
|
+
private readonly currentSpec: string;
|
|
97
|
+
private readonly providers: readonly string[];
|
|
98
|
+
private readonly modelsByProvider: ReadonlyMap<string, readonly ResolvedModelEntry[]>;
|
|
99
|
+
private readonly done: (result: SelectionResult | undefined) => void;
|
|
100
|
+
private readonly theme: SelectListTheme;
|
|
101
|
+
private _resolved = false;
|
|
102
|
+
private currentList: SelectList | null = null;
|
|
103
|
+
|
|
104
|
+
constructor(
|
|
105
|
+
currentSpec: string,
|
|
106
|
+
providers: readonly string[],
|
|
107
|
+
modelsByProvider: ReadonlyMap<string, readonly ResolvedModelEntry[]>,
|
|
108
|
+
done: (result: SelectionResult | undefined) => void,
|
|
109
|
+
theme: SelectListTheme = DEFAULT_SELECT_THEME,
|
|
110
|
+
) {
|
|
111
|
+
super();
|
|
112
|
+
this.currentSpec = currentSpec;
|
|
113
|
+
this.providers = providers;
|
|
114
|
+
this.modelsByProvider = modelsByProvider;
|
|
115
|
+
this.done = done;
|
|
116
|
+
this.theme = theme;
|
|
117
|
+
this.switchToProviderStage();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Container 无 handleInput(Container implements Component 但未声明 handleInput),
|
|
122
|
+
* 必须在此声明委托给当前 stage 的 SelectList(WC9/WC15 critical)。
|
|
123
|
+
* 不能用 override(Container 未声明该方法),直接新增方法满足 Component 接口。
|
|
124
|
+
*/
|
|
125
|
+
handleInput(data: string): void {
|
|
126
|
+
this.currentList?.handleInput(data);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** box 边框左右各占用 1 列(│ × 2) */
|
|
130
|
+
private static readonly BORDER_OVERHEAD = 2;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 重写 render:用 box 边框包裹 Container 子组件的输出。
|
|
134
|
+
*/
|
|
135
|
+
override render(width: number): string[] {
|
|
136
|
+
const innerWidth = Math.max(0, width - ProviderModelSelectorComponent.BORDER_OVERHEAD);
|
|
137
|
+
const inner = super.render(innerWidth);
|
|
138
|
+
const lines: string[] = [];
|
|
139
|
+
lines.push(`\u250C${"\u2500".repeat(innerWidth)}\u2510`);
|
|
140
|
+
for (const line of inner) {
|
|
141
|
+
const padded = truncateToWidth(line, innerWidth, "", true);
|
|
142
|
+
lines.push(`\u2502${padded}\u2502`);
|
|
143
|
+
}
|
|
144
|
+
lines.push(`\u2514${"\u2500".repeat(innerWidth)}\u2518`);
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 退出(外部 abort 用)。复用 _resolved 守卫防二次 done。 */
|
|
149
|
+
cancel(): void {
|
|
150
|
+
this.settle(undefined);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private settle(result: SelectionResult | undefined): void {
|
|
154
|
+
if (this._resolved) return;
|
|
155
|
+
this._resolved = true;
|
|
156
|
+
this.done(result);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ──────────────────────── provider stage ────────────────────────
|
|
160
|
+
|
|
161
|
+
private switchToProviderStage(): void {
|
|
162
|
+
this.stage = "provider";
|
|
163
|
+
this.clear();
|
|
164
|
+
this.currentList = this.buildProviderList();
|
|
165
|
+
this.addChild(this.buildTitleBox("Select Provider", this.formatProviderHint()));
|
|
166
|
+
this.addChild(this.currentList);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 构建 provider stage 的 SelectList(title + list 由调用方组装)。 */
|
|
170
|
+
private buildProviderList(): SelectList {
|
|
171
|
+
const selectedIndex = this.computeProviderSelectedIndex();
|
|
172
|
+
const items = this.buildProviderItems();
|
|
173
|
+
const list = new SelectList(items, MAX_VISIBLE_ITEMS, this.theme);
|
|
174
|
+
list.setSelectedIndex(selectedIndex);
|
|
175
|
+
list.onSelect = (item: SelectItem): void => {
|
|
176
|
+
if (item.value === AUTO_VALUE) {
|
|
177
|
+
this.settle({ kind: "auto" });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this.switchToModelStage(item.value);
|
|
181
|
+
};
|
|
182
|
+
list.onCancel = (): void => {
|
|
183
|
+
this.settle(undefined);
|
|
184
|
+
};
|
|
185
|
+
return list;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** provider stage items:'Auto' + 各 provider(含 model 数量)。 */
|
|
189
|
+
private buildProviderItems(): SelectItem[] {
|
|
190
|
+
const items: SelectItem[] = [
|
|
191
|
+
{ value: AUTO_VALUE, label: "Auto", description: "auto-select cheapest available model" },
|
|
192
|
+
];
|
|
193
|
+
for (const provider of this.providers) {
|
|
194
|
+
const models = this.modelsByProvider.get(provider) ?? [];
|
|
195
|
+
items.push({
|
|
196
|
+
value: provider,
|
|
197
|
+
label: provider,
|
|
198
|
+
description: `${models.length} model(s)`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return items;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 计算 provider stage 预选 index(currentSpec='auto' → 0;'provider/model' → findIndex)。 */
|
|
205
|
+
private computeProviderSelectedIndex(): number {
|
|
206
|
+
if (this.currentSpec === "auto") return 0;
|
|
207
|
+
const slashIdx = this.currentSpec.indexOf("/");
|
|
208
|
+
if (slashIdx <= 0) return 0;
|
|
209
|
+
const provider = this.currentSpec.slice(0, slashIdx);
|
|
210
|
+
const idx = this.providers.indexOf(provider);
|
|
211
|
+
return idx >= 0 ? idx + 1 : 0; // +1 跳过 'Auto'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private formatProviderHint(): string {
|
|
215
|
+
return `Current: ${this.currentSpec} | [Up/Down] navigate [Enter] select [Esc] cancel`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ──────────────────────── model stage ────────────────────────
|
|
219
|
+
|
|
220
|
+
private switchToModelStage(provider: string): void {
|
|
221
|
+
this.stage = "model";
|
|
222
|
+
this.clear();
|
|
223
|
+
const models = this.modelsByProvider.get(provider) ?? [];
|
|
224
|
+
this.currentList = this.buildModelList(provider, models);
|
|
225
|
+
this.addChild(this.buildTitleBox(`Select Model - ${provider}`, this.formatModelHint()));
|
|
226
|
+
this.addChild(this.currentList);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 构建 model stage 的 SelectList(按 cost 升序,由 modelsByProvider 保证)。 */
|
|
230
|
+
private buildModelList(provider: string, models: readonly ResolvedModelEntry[]): SelectList {
|
|
231
|
+
const items: SelectItem[] = models.map((m) => ({
|
|
232
|
+
value: m.id,
|
|
233
|
+
label: m.id,
|
|
234
|
+
description: `input cost: ${m.cost.input}`,
|
|
235
|
+
}));
|
|
236
|
+
const list = new SelectList(items, MAX_VISIBLE_ITEMS, this.theme);
|
|
237
|
+
const preSelected = this.computeModelSelectedIndex(models);
|
|
238
|
+
list.setSelectedIndex(preSelected);
|
|
239
|
+
list.onSelect = (item: SelectItem): void => {
|
|
240
|
+
this.settle({ kind: "specific", provider, modelId: item.value });
|
|
241
|
+
};
|
|
242
|
+
list.onCancel = (): void => {
|
|
243
|
+
// Esc 退回 provider stage(不 settle)
|
|
244
|
+
this.switchToProviderStage();
|
|
245
|
+
};
|
|
246
|
+
return list;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 计算 model stage 预选 index。 */
|
|
250
|
+
private computeModelSelectedIndex(models: readonly ResolvedModelEntry[]): number {
|
|
251
|
+
const slashIdx = this.currentSpec.indexOf("/");
|
|
252
|
+
if (slashIdx <= 0) return 0;
|
|
253
|
+
const modelId = this.currentSpec.slice(slashIdx + 1);
|
|
254
|
+
const idx = models.findIndex((m) => m.id === modelId);
|
|
255
|
+
return idx >= 0 ? idx : 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private formatModelHint(): string {
|
|
259
|
+
return "[Up/Down] navigate [Enter] select [Esc] back to provider list";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ──────────────────────── title box(G4) ────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* G4:title/hint box(含当前选中提示 + 键位提示)。
|
|
266
|
+
* 用 Container 包裹 TextLines(保持纯文本,不依赖 Box 的 padding/bg)。
|
|
267
|
+
*/
|
|
268
|
+
private buildTitleBox(title: string, hint: string): Container {
|
|
269
|
+
const box = new Container();
|
|
270
|
+
box.addChild(new TextLines([`[pi-permission] ${title}`]));
|
|
271
|
+
box.addChild(new TextLines([hint, ""]));
|
|
272
|
+
return box;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ──────────────────────── pickModelViaOverlay ────────────────────────
|
|
277
|
+
|
|
278
|
+
/** overlay 上下文(从 Pi ExtensionContext 提取的最小子集)。 */
|
|
279
|
+
export interface ModelPickerContext {
|
|
280
|
+
mode: "tui" | "rpc" | "json" | "print";
|
|
281
|
+
ui: {
|
|
282
|
+
notify(msg: string, type?: "info" | "warning" | "error"): void;
|
|
283
|
+
select(title: string, options: string[], opts?: { signal?: AbortSignal; timeout?: number }): Promise<string | undefined>;
|
|
284
|
+
custom<T = void>(
|
|
285
|
+
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
286
|
+
options?: { overlay?: boolean },
|
|
287
|
+
): Promise<T>;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** listAvailableModels 注入签名(便于测试 mock)。 */
|
|
292
|
+
export type ListAvailableModelsFn = (
|
|
293
|
+
onWarning?: (msg: string) => void,
|
|
294
|
+
filePath?: string,
|
|
295
|
+
) => Map<string, ResolvedModelEntry[]>;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 通过 overlay 选择模型(provider/model 或 auto)。
|
|
299
|
+
*
|
|
300
|
+
* @param ctx UI 上下文(mode + ui.*)
|
|
301
|
+
* @param currentSpec 当前 classifier.model('auto' 或 'provider/model-id'),用于预选
|
|
302
|
+
* @param models 可选,预加载的 models Map(避免重复读盘);未传则用默认 listAvailableModels
|
|
303
|
+
* @returns 'auto' / 'provider/model-id' / undefined(cancel 或 headless 降级)
|
|
304
|
+
*/
|
|
305
|
+
export async function pickModelViaOverlay(
|
|
306
|
+
ctx: ModelPickerContext,
|
|
307
|
+
currentSpec: string,
|
|
308
|
+
models?: Map<string, ResolvedModelEntry[]>,
|
|
309
|
+
): Promise<string | undefined> {
|
|
310
|
+
const modelsByProvider = models ?? listAvailableModelsDefault();
|
|
311
|
+
if (modelsByProvider.size === 0) return undefined;
|
|
312
|
+
|
|
313
|
+
const providers = [...modelsByProvider.keys()];
|
|
314
|
+
|
|
315
|
+
switch (ctx.mode) {
|
|
316
|
+
case "tui":
|
|
317
|
+
return await pickViaTui(ctx, currentSpec, providers, modelsByProvider);
|
|
318
|
+
case "rpc":
|
|
319
|
+
return await pickViaRpc(ctx, currentSpec, providers, modelsByProvider);
|
|
320
|
+
case "json":
|
|
321
|
+
case "print":
|
|
322
|
+
default:
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 默认 listAvailableModels(生产路径用,index.ts 装配时注入真实实现)。 */
|
|
328
|
+
let listAvailableModelsDefault: ListAvailableModelsFn = (): Map<string, ResolvedModelEntry[]> => new Map();
|
|
329
|
+
|
|
330
|
+
/** 注入默认 listAvailableModels 实现(index.ts 装配时调用,或测试覆盖)。 */
|
|
331
|
+
export function setDefaultListAvailableModels(fn: ListAvailableModelsFn): void {
|
|
332
|
+
listAvailableModelsDefault = fn;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ──────────────────────── TUI 分支 ────────────────────────
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* TUI 分支:ctx.ui.custom + ProviderModelSelectorComponent。
|
|
339
|
+
* custom 的 factory 接收 done 回调;comp 内部 onSelect/onCancel 最终调 done。
|
|
340
|
+
*/
|
|
341
|
+
async function pickViaTui(
|
|
342
|
+
ctx: ModelPickerContext,
|
|
343
|
+
currentSpec: string,
|
|
344
|
+
providers: readonly string[],
|
|
345
|
+
modelsByProvider: ReadonlyMap<string, readonly ResolvedModelEntry[]>,
|
|
346
|
+
): Promise<string | undefined> {
|
|
347
|
+
const result = await ctx.ui.custom<SelectionResult | undefined>(
|
|
348
|
+
(_tui, _theme, _kb, done) => {
|
|
349
|
+
const comp = new ProviderModelSelectorComponent(
|
|
350
|
+
currentSpec,
|
|
351
|
+
providers,
|
|
352
|
+
modelsByProvider,
|
|
353
|
+
done,
|
|
354
|
+
);
|
|
355
|
+
return comp;
|
|
356
|
+
},
|
|
357
|
+
{ overlay: true },
|
|
358
|
+
);
|
|
359
|
+
return selectionResultToString(result);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ──────────────────────── RPC 分支 ────────────────────────
|
|
363
|
+
|
|
364
|
+
async function pickViaRpc(
|
|
365
|
+
ctx: ModelPickerContext,
|
|
366
|
+
currentSpec: string,
|
|
367
|
+
providers: readonly string[],
|
|
368
|
+
modelsByProvider: ReadonlyMap<string, readonly ResolvedModelEntry[]>,
|
|
369
|
+
): Promise<string | undefined> {
|
|
370
|
+
// 第一次 select:provider(含 Auto)
|
|
371
|
+
const providerOptions = ["Auto", ...providers];
|
|
372
|
+
const providerChoice = await ctx.ui.select(
|
|
373
|
+
`[pi-permission] Select provider (current: ${currentSpec})`,
|
|
374
|
+
providerOptions,
|
|
375
|
+
);
|
|
376
|
+
if (providerChoice === undefined) return undefined;
|
|
377
|
+
if (providerChoice === "Auto") return "auto";
|
|
378
|
+
|
|
379
|
+
// 第二次 select:model
|
|
380
|
+
const models = modelsByProvider.get(providerChoice) ?? [];
|
|
381
|
+
if (models.length === 0) return undefined;
|
|
382
|
+
const modelOptions = models.map((m) => m.id);
|
|
383
|
+
const modelChoice = await ctx.ui.select(
|
|
384
|
+
`[pi-permission] Select model for ${providerChoice}`,
|
|
385
|
+
modelOptions,
|
|
386
|
+
);
|
|
387
|
+
if (modelChoice === undefined) return undefined;
|
|
388
|
+
return `${providerChoice}/${modelChoice}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ──────────────────────── 辅助 ────────────────────────
|
|
392
|
+
|
|
393
|
+
/** SelectionResult → config 字符串('auto' / 'provider/model-id')。 */
|
|
394
|
+
function selectionResultToString(result: SelectionResult | undefined): string | undefined {
|
|
395
|
+
if (result === undefined) return undefined;
|
|
396
|
+
if (result.kind === "auto") return "auto";
|
|
397
|
+
return `${result.provider}/${result.modelId}`;
|
|
398
|
+
}
|