@bachi/pi-coder 1.0.0 → 1.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/CHANGELOG.md +19 -0
- package/README.md +16 -7
- package/docs/configuration.md +9 -1
- package/docs/development.md +26 -11
- package/docs/extensions.md +36 -1
- package/docs/handbook.zh.md +87 -2
- package/docs/installation.md +3 -1
- package/docs/themes.md +3 -2
- package/extensions/mcp/client.test.ts +518 -0
- package/extensions/mcp/client.ts +796 -0
- package/extensions/mcp/config.test.ts +307 -0
- package/extensions/mcp/config.ts +360 -0
- package/extensions/mcp/fixtures/fake-mcp-server.mjs +155 -0
- package/extensions/mcp/fixtures/token-helper.mjs +51 -0
- package/extensions/mcp/headers-command.test.ts +172 -0
- package/extensions/mcp/headers-command.ts +203 -0
- package/extensions/mcp/index.ts +295 -0
- package/extensions/mcp/protocol.test.ts +179 -0
- package/extensions/mcp/protocol.ts +239 -0
- package/extensions/mcp/tools.test.ts +236 -0
- package/extensions/mcp/tools.ts +339 -0
- package/extensions/statusline/footer-suppress.test.ts +150 -0
- package/extensions/statusline/footer-suppress.ts +135 -0
- package/extensions/statusline/index.ts +17 -0
- package/package.json +4 -3
- package/themes/ayu.json +3 -2
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools.ts — MCP 工具 → pi 工具的纯映射层:命名、schema、结果内容、截断。
|
|
3
|
+
*
|
|
4
|
+
* 纯逻辑,不 import pi / 不 spawn 进程,所以 `node --test` 直接跑。pi 的注册动作全在 index.ts,
|
|
5
|
+
* 这里只回答「这个名字怎么拼」「这段内容怎么给模型」。
|
|
6
|
+
*
|
|
7
|
+
* 命名采用 Claude Code 的 `mcp__<server>__<tool>`:用户的 skill 与权限规则都是按这个形状写的,
|
|
8
|
+
* 换一套前缀只会让两边对不上。(pi-mcp-adapter 默认是 `<server>_<tool>`,我们在 `mcp__` 上
|
|
9
|
+
* 更贴 Claude Code 的习惯。)
|
|
10
|
+
*
|
|
11
|
+
* 结果映射的两个必须遵守的约束:
|
|
12
|
+
* - pi 的 tool content 只认 `{type:"text"}` 与 `{type:"image", data, mimeType}`;
|
|
13
|
+
* MCP 的 `resource` / `resource_link` / `audio` 都得降级成文本说明,不能原样塞进去。
|
|
14
|
+
* - 工具输出必须截断:MCP 服务端(尤其是把整个 JSON 一次吐出来的实现)很容易给出
|
|
15
|
+
* 超过上下文的体积。截断上限沿用 pi 内建工具的 50KB / 2000 行。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** MCP 的 image 内容块 → pi 的 ImageContent 形状。 */
|
|
19
|
+
export interface PiTextContent {
|
|
20
|
+
type: "text";
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PiImageContent {
|
|
25
|
+
type: "image";
|
|
26
|
+
data: string;
|
|
27
|
+
mimeType: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PiToolContent = PiTextContent | PiImageContent;
|
|
31
|
+
|
|
32
|
+
/** 工具名上限:Anthropic / OpenAI 的 tool 名都是 64 字符。 */
|
|
33
|
+
export const MAX_TOOL_NAME_LENGTH = 64;
|
|
34
|
+
|
|
35
|
+
/** 与 pi 内建工具一致的截断上限。 */
|
|
36
|
+
export const DEFAULT_MAX_BYTES = 50 * 1024;
|
|
37
|
+
export const DEFAULT_MAX_LINES = 2000;
|
|
38
|
+
|
|
39
|
+
export const MCP_TOOL_PREFIX = "mcp__";
|
|
40
|
+
export const TOOL_PREFIX_SEPARATOR = "__";
|
|
41
|
+
|
|
42
|
+
export interface McpToolDescriptor {
|
|
43
|
+
name: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
inputSchema?: unknown;
|
|
46
|
+
annotations?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `mcp__<server>__<tool>`,非法字符替换成 `_`。
|
|
51
|
+
*
|
|
52
|
+
* 超过 64 字符时截断工具名并接一个 6 位哈希后缀:名字要能被模型原样回传,截断后的
|
|
53
|
+
* 冲突(两个长工具名截到同一段)靠哈希区分。哈希是 FNV-1a,只为消歧,不做安全用途。
|
|
54
|
+
*/
|
|
55
|
+
export function piToolName(serverName: string, toolName: string): string {
|
|
56
|
+
const server = sanitizeSegment(serverName);
|
|
57
|
+
const tool = sanitizeSegment(toolName);
|
|
58
|
+
const full = `${MCP_TOOL_PREFIX}${server}${TOOL_PREFIX_SEPARATOR}${tool}`;
|
|
59
|
+
if (full.length <= MAX_TOOL_NAME_LENGTH) return full;
|
|
60
|
+
|
|
61
|
+
const hash = fnv1a(`${serverName}\u0000${toolName}`);
|
|
62
|
+
const budget = MAX_TOOL_NAME_LENGTH - MCP_TOOL_PREFIX.length - server.length - TOOL_PREFIX_SEPARATOR.length - hash.length - 1;
|
|
63
|
+
const head = tool.slice(0, Math.max(budget, 8));
|
|
64
|
+
return `${MCP_TOOL_PREFIX}${server}${TOOL_PREFIX_SEPARATOR}${head}_${hash}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sanitizeSegment(value: string): string {
|
|
68
|
+
const sanitized = value.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
69
|
+
return sanitized === "" ? "_" : sanitized;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fnv1a(value: string): string {
|
|
73
|
+
let hash = 0x811c9dc5;
|
|
74
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
75
|
+
hash ^= value.charCodeAt(index);
|
|
76
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
77
|
+
}
|
|
78
|
+
return hash.toString(16).padStart(8, "0").slice(0, 6);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 归一化 MCP 的 inputSchema,保证是一个合法的 `type: "object"` JSON Schema。
|
|
83
|
+
*
|
|
84
|
+
* 现实里的服务端会给出各种形状:缺 schema、`{}`、`{"properties": ...}` 没写 type、
|
|
85
|
+
* 甚至直接是 `null`。pi 用这个 schema 校验参数,形状不对会让整次调用在下发前就失败 ——
|
|
86
|
+
* 兜底成「无参数对象」最坏只是模型看不到参数提示,比调用被拒好。
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeInputSchema(schema: unknown): Record<string, unknown> {
|
|
89
|
+
const empty: Record<string, unknown> = { type: "object", properties: {} };
|
|
90
|
+
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return empty;
|
|
91
|
+
const record = schema as Record<string, unknown>;
|
|
92
|
+
const type = record.type;
|
|
93
|
+
if (type !== undefined && type !== "object") return empty;
|
|
94
|
+
return {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties:
|
|
97
|
+
typeof record.properties === "object" && record.properties !== null && !Array.isArray(record.properties)
|
|
98
|
+
? record.properties
|
|
99
|
+
: {},
|
|
100
|
+
...(Array.isArray(record.required) ? { required: record.required } : {}),
|
|
101
|
+
// 其余 JSON Schema 关键字($defs / additionalProperties / oneOf ...)原样保留。
|
|
102
|
+
...Object.fromEntries(
|
|
103
|
+
Object.entries(record).filter(([key]) => !["type", "properties", "required"].includes(key)),
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 给模型的工具描述:原文 + 注解提示(只读/破坏性)。 */
|
|
109
|
+
export function toolDescription(serverName: string, tool: McpToolDescriptor): string {
|
|
110
|
+
const parts: string[] = [];
|
|
111
|
+
if (tool.description?.trim()) parts.push(tool.description.trim());
|
|
112
|
+
const hints = annotationHints(tool.annotations);
|
|
113
|
+
const header = `MCP 工具(server: ${serverName})${hints ? ` ${hints}` : ""}`;
|
|
114
|
+
parts.push(header);
|
|
115
|
+
return parts.join("\n\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function annotationHints(annotations: Record<string, unknown> | undefined): string {
|
|
119
|
+
if (!annotations) return "";
|
|
120
|
+
const hints: string[] = [];
|
|
121
|
+
if (annotations.readOnlyHint === true) hints.push("只读");
|
|
122
|
+
if (annotations.destructiveHint === true) hints.push("可能破坏数据");
|
|
123
|
+
if (annotations.idempotentHint === true) hints.push("幂等");
|
|
124
|
+
if (annotations.openWorldHint === true) hints.push("访问外部世界");
|
|
125
|
+
return hints.length > 0 ? `[${hints.join(" / ")}]` : "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 系统提示里的一行摘要(pi 的 `Available tools` 段)。 */
|
|
129
|
+
export function toolPromptSnippet(description: string | undefined): string | undefined {
|
|
130
|
+
if (!description?.trim()) return undefined;
|
|
131
|
+
const flat = description.replace(/\s+/g, " ").trim();
|
|
132
|
+
return flat.length <= 100 ? flat : `${truncateAtWord(flat, 100)}…`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function truncateAtWord(value: string, max: number): string {
|
|
136
|
+
if (value.length <= max) return value;
|
|
137
|
+
const slice = value.slice(0, max);
|
|
138
|
+
const space = slice.lastIndexOf(" ");
|
|
139
|
+
// CJK 里没有空格:切不到词就硬切,别把整段都丢掉。
|
|
140
|
+
return space > max * 0.6 ? slice.slice(0, space) : slice;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ContentMappingResult {
|
|
144
|
+
content: PiToolContent[];
|
|
145
|
+
/** 给 LLM 看的纯文本(文本块拼接;含图片时的占位说明)。 */
|
|
146
|
+
text: string;
|
|
147
|
+
/** 无法直传、被降级成文本说明的内容块(音频、二进制 resource 等)。 */
|
|
148
|
+
notes: string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* MCP `tools/call` 的 content 数组 → pi 的 content 数组。
|
|
153
|
+
*
|
|
154
|
+
* 认得的形状:text / image / resource / resource_link。
|
|
155
|
+
* 认不得的一律降级成一行文本说明 —— 宁可让模型看到「这里有个不支持的 X」,
|
|
156
|
+
* 也不要静默丢掉内容(模型会以为自己没看到东西)。
|
|
157
|
+
*/
|
|
158
|
+
export function mcpContentToPiContent(content: unknown[]): ContentMappingResult {
|
|
159
|
+
const blocks: PiToolContent[] = [];
|
|
160
|
+
const notes: string[] = [];
|
|
161
|
+
const texts: string[] = [];
|
|
162
|
+
|
|
163
|
+
for (const raw of content) {
|
|
164
|
+
if (typeof raw !== "object" || raw === null) {
|
|
165
|
+
if (raw !== undefined && raw !== null) {
|
|
166
|
+
const text = stringifyUnknown(raw);
|
|
167
|
+
texts.push(text);
|
|
168
|
+
blocks.push({ type: "text", text });
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const block = raw as Record<string, unknown>;
|
|
173
|
+
switch (block.type) {
|
|
174
|
+
case "text": {
|
|
175
|
+
const text = typeof block.text === "string" ? block.text : stringifyUnknown(block);
|
|
176
|
+
texts.push(text);
|
|
177
|
+
blocks.push({ type: "text", text });
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case "image": {
|
|
181
|
+
const image = toImageBlock(block);
|
|
182
|
+
if (image) {
|
|
183
|
+
blocks.push(image);
|
|
184
|
+
texts.push(`[图片 ${image.mimeType}]`);
|
|
185
|
+
} else {
|
|
186
|
+
const note = "[图片缺少 data/mimeType,无法传给模型]";
|
|
187
|
+
notes.push(note);
|
|
188
|
+
texts.push(note);
|
|
189
|
+
blocks.push({ type: "text", text: note });
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "audio": {
|
|
194
|
+
const note = `[音频内容(${typeof block.mimeType === "string" ? block.mimeType : "未知类型"})无法直接传给模型]`;
|
|
195
|
+
notes.push(note);
|
|
196
|
+
texts.push(note);
|
|
197
|
+
blocks.push({ type: "text", text: note });
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case "resource": {
|
|
201
|
+
const mapped = resourceToContent(block.resource);
|
|
202
|
+
notes.push(...mapped.notes);
|
|
203
|
+
texts.push(mapped.text);
|
|
204
|
+
blocks.push(...mapped.content);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "resource_link": {
|
|
208
|
+
const uri = typeof block.uri === "string" ? block.uri : "(无 uri)";
|
|
209
|
+
const name = typeof block.name === "string" ? block.name : undefined;
|
|
210
|
+
const note = `[资源链接${name ? ` ${name}` : ""}: ${uri}]`;
|
|
211
|
+
notes.push(note);
|
|
212
|
+
texts.push(note);
|
|
213
|
+
blocks.push({ type: "text", text: note });
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
default: {
|
|
217
|
+
const note = `[不支持的 MCP 内容块 ${String(block.type)}: ${stringifyUnknown(block).slice(0, 200)}]`;
|
|
218
|
+
notes.push(note);
|
|
219
|
+
texts.push(note);
|
|
220
|
+
blocks.push({ type: "text", text: note });
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { content: blocks, text: texts.join("\n"), notes };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function resourceToContent(rawResource: unknown): { content: PiToolContent[]; text: string; notes: string[] } {
|
|
230
|
+
if (typeof rawResource !== "object" || rawResource === null) {
|
|
231
|
+
const note = "[无效的 MCP resource 内容块]";
|
|
232
|
+
return { content: [{ type: "text", text: note }], text: note, notes: [note] };
|
|
233
|
+
}
|
|
234
|
+
const resource = rawResource as Record<string, unknown>;
|
|
235
|
+
const uri = typeof resource.uri === "string" ? resource.uri : "(无 uri)";
|
|
236
|
+
const mimeType = typeof resource.mimeType === "string" ? resource.mimeType : undefined;
|
|
237
|
+
|
|
238
|
+
if (typeof resource.text === "string") {
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text", text: resource.text }],
|
|
241
|
+
text: resource.text,
|
|
242
|
+
notes: [],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (typeof resource.blob === "string") {
|
|
246
|
+
const image = mimeType?.startsWith("image/")
|
|
247
|
+
? toImageBlock({ data: resource.blob, mimeType })
|
|
248
|
+
: undefined;
|
|
249
|
+
if (image) {
|
|
250
|
+
return { content: [image], text: `[图片 ${mimeType}]`, notes: [] };
|
|
251
|
+
}
|
|
252
|
+
const note = `[二进制资源 ${uri}(${mimeType ?? "未知类型"},${formatBytes(Math.floor((resource.blob.length * 3) / 4))})未传给模型]`;
|
|
253
|
+
return { content: [{ type: "text", text: note }], text: note, notes: [note] };
|
|
254
|
+
}
|
|
255
|
+
const note = `[资源 ${uri} 没有 text/blob 内容]`;
|
|
256
|
+
return { content: [{ type: "text", text: note }], text: note, notes: [note] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function toImageBlock(block: Record<string, unknown>): PiImageContent | undefined {
|
|
260
|
+
const data = typeof block.data === "string" ? block.data : undefined;
|
|
261
|
+
const mimeType = typeof block.mimeType === "string" ? block.mimeType : undefined;
|
|
262
|
+
if (!data || !mimeType) return undefined;
|
|
263
|
+
return { type: "image", data, mimeType };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function stringifyUnknown(value: unknown): string {
|
|
267
|
+
if (typeof value === "string") return value;
|
|
268
|
+
try {
|
|
269
|
+
const json = JSON.stringify(value);
|
|
270
|
+
return json === undefined ? String(value) : json;
|
|
271
|
+
} catch {
|
|
272
|
+
return String(value);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface TruncateOptions {
|
|
277
|
+
maxBytes?: number;
|
|
278
|
+
maxLines?: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface TruncateResult {
|
|
282
|
+
text: string;
|
|
283
|
+
truncated: boolean;
|
|
284
|
+
totalBytes: number;
|
|
285
|
+
totalLines: number;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 头截断:保留前 N 行 / 前 N 字节。
|
|
290
|
+
*
|
|
291
|
+
* 头截断而不是尾截断:MCP 工具返回的常见形态是一段 JSON 或一段列表,开头大概率是元信息
|
|
292
|
+
* (消息数、分页游标、schema 名),比结尾更有价值。
|
|
293
|
+
*/
|
|
294
|
+
export function truncateText(text: string, options: TruncateOptions = {}): TruncateResult {
|
|
295
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
296
|
+
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
297
|
+
const totalBytes = Buffer.byteLength(text, "utf8");
|
|
298
|
+
const totalLines = countLines(text);
|
|
299
|
+
|
|
300
|
+
if (totalBytes <= maxBytes && totalLines <= maxLines) {
|
|
301
|
+
return { text, truncated: false, totalBytes, totalLines };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let head = text;
|
|
305
|
+
if (totalLines > maxLines) {
|
|
306
|
+
const lines = text.split("\n", maxLines);
|
|
307
|
+
head = lines.join("\n");
|
|
308
|
+
}
|
|
309
|
+
if (Buffer.byteLength(head, "utf8") > maxBytes) {
|
|
310
|
+
head = Buffer.from(head, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
311
|
+
// 截在多字节字符中间时尾部会留一个替换字符,去掉它。
|
|
312
|
+
if (head.endsWith("\uFFFD")) head = head.slice(0, -1);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const keptBytes = Buffer.byteLength(head, "utf8");
|
|
316
|
+
return {
|
|
317
|
+
text:
|
|
318
|
+
`${head}\n\n[输出已截断:保留 ${formatBytes(keptBytes)} / ${totalBytes} 字节,` +
|
|
319
|
+
`${countLines(head)} / ${totalLines} 行。需要完整内容请缩小查询范围(时间区间 / limit / 关键词)后重试。]`,
|
|
320
|
+
truncated: true,
|
|
321
|
+
totalBytes,
|
|
322
|
+
totalLines,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function countLines(text: string): number {
|
|
327
|
+
if (text === "") return 0;
|
|
328
|
+
let lines = 1;
|
|
329
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
330
|
+
if (text.charCodeAt(index) === 10) lines += 1;
|
|
331
|
+
}
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function formatBytes(bytes: number): string {
|
|
336
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
337
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
338
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
339
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
FOOTER_SUPPRESS_KEY,
|
|
5
|
+
FOOTER_SUPPRESS_MAX_AGE_MS,
|
|
6
|
+
releaseFooterSuppressor,
|
|
7
|
+
suppressBuiltInFooter,
|
|
8
|
+
} from "./footer-suppress.ts";
|
|
9
|
+
|
|
10
|
+
/** 仿 pi 的 `FooterComponent`:render 是原型方法,实例自己不带 render。 */
|
|
11
|
+
class FakeFooter {
|
|
12
|
+
/** 真原版被调用的次数(断言「屏蔽期间一帧都没出真内容」用)。 */
|
|
13
|
+
originalRenders = 0;
|
|
14
|
+
|
|
15
|
+
render(width: number): string[] {
|
|
16
|
+
this.originalRenders += 1;
|
|
17
|
+
return [`pi-default#${width}`, `stats#${width}`];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function recordOf(): unknown {
|
|
22
|
+
return (FakeFooter.prototype as { [FOOTER_SUPPRESS_KEY]?: unknown })[FOOTER_SUPPRESS_KEY];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("屏蔽期间内置 footer 渲染 0 行,解除后恢复原渲染", () => {
|
|
26
|
+
const footer = new FakeFooter();
|
|
27
|
+
const release = suppressBuiltInFooter(FakeFooter);
|
|
28
|
+
assert.equal(typeof release, "function");
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(footer.render(80), []);
|
|
31
|
+
assert.deepEqual(footer.render(40), []);
|
|
32
|
+
// 屏蔽期间真原版一次都没跑 → 屏幕上不会出现默认状态行。
|
|
33
|
+
assert.equal(footer.originalRenders, 0);
|
|
34
|
+
|
|
35
|
+
release();
|
|
36
|
+
assert.deepEqual(footer.render(80), ["pi-default#80", "stats#80"]);
|
|
37
|
+
assert.equal(footer.originalRenders, 1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("解除后不留接管记录,且幂等", () => {
|
|
41
|
+
const release = suppressBuiltInFooter(FakeFooter)!;
|
|
42
|
+
release();
|
|
43
|
+
assert.equal(recordOf(), undefined);
|
|
44
|
+
assert.equal(releaseFooterSuppressor(FakeFooter), false);
|
|
45
|
+
|
|
46
|
+
// 重复解除不再改动任何东西(原型方法还是那个真原版)。
|
|
47
|
+
const before = FakeFooter.prototype.render;
|
|
48
|
+
release();
|
|
49
|
+
release();
|
|
50
|
+
assert.equal(FakeFooter.prototype.render, before);
|
|
51
|
+
assert.deepEqual(new FakeFooter().render(5), ["pi-default#5", "stats#5"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("重复接管先交还上一次,不叠 wrapper", () => {
|
|
55
|
+
const footer = new FakeFooter();
|
|
56
|
+
const releaseFirst = suppressBuiltInFooter(FakeFooter)!;
|
|
57
|
+
assert.deepEqual(footer.render(10), []);
|
|
58
|
+
|
|
59
|
+
// 模拟 `/reload`:模块重新求值 → 新实例再打一次。
|
|
60
|
+
const releaseSecond = suppressBuiltInFooter(FakeFooter)!;
|
|
61
|
+
assert.deepEqual(footer.render(10), []);
|
|
62
|
+
assert.equal(footer.originalRenders, 0);
|
|
63
|
+
|
|
64
|
+
// 只解第二次:若还叠着第一层 wrapper,这里会继续出空行。
|
|
65
|
+
releaseSecond();
|
|
66
|
+
assert.deepEqual(footer.render(10), ["pi-default#10", "stats#10"]);
|
|
67
|
+
assert.equal(footer.originalRenders, 1);
|
|
68
|
+
|
|
69
|
+
releaseFirst();
|
|
70
|
+
assert.deepEqual(footer.render(10), ["pi-default#10", "stats#10"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("releaseFooterSuppressor 能解除别的扩展实例留下的接管", () => {
|
|
74
|
+
suppressBuiltInFooter(FakeFooter);
|
|
75
|
+
assert.equal(new FakeFooter().render(7).length, 0);
|
|
76
|
+
|
|
77
|
+
assert.equal(releaseFooterSuppressor(FakeFooter), true);
|
|
78
|
+
assert.equal(releaseFooterSuppressor(FakeFooter), false);
|
|
79
|
+
assert.deepEqual(new FakeFooter().render(7), ["pi-default#7", "stats#7"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("超时后自愈:交还原渲染并清掉接管记录", () => {
|
|
83
|
+
let clock = 1000;
|
|
84
|
+
const footer = new FakeFooter();
|
|
85
|
+
suppressBuiltInFooter(FakeFooter, { maxAgeMs: 500, now: () => clock });
|
|
86
|
+
assert.deepEqual(footer.render(9), []);
|
|
87
|
+
|
|
88
|
+
// 还没到点:仍然屏蔽。
|
|
89
|
+
clock = 1500;
|
|
90
|
+
assert.deepEqual(footer.render(9), []);
|
|
91
|
+
|
|
92
|
+
// 过点:自动交还,后面几帧都走真原版。
|
|
93
|
+
clock = 1501;
|
|
94
|
+
assert.deepEqual(footer.render(9), ["pi-default#9", "stats#9"]);
|
|
95
|
+
assert.equal(recordOf(), undefined);
|
|
96
|
+
assert.deepEqual(footer.render(9), ["pi-default#9", "stats#9"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("默认兜底时长能盖住慢启动(`mcp` 握手 20s 上限),且是个正数", () => {
|
|
100
|
+
assert.ok(FOOTER_SUPPRESS_MAX_AGE_MS >= 25_000);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("形状不对时什么都不做(pi 改了结构 / 传错东西)", () => {
|
|
104
|
+
assert.equal(suppressBuiltInFooter(undefined), undefined);
|
|
105
|
+
assert.equal(suppressBuiltInFooter(null), undefined);
|
|
106
|
+
assert.equal(suppressBuiltInFooter("FooterComponent"), undefined);
|
|
107
|
+
assert.equal(suppressBuiltInFooter({}), undefined);
|
|
108
|
+
assert.equal(suppressBuiltInFooter({ prototype: {} }), undefined);
|
|
109
|
+
assert.equal(suppressBuiltInFooter({ prototype: { render: "not-a-function" } }), undefined);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("直接传「带 render 的对象」也能接管(不要求是 class)", () => {
|
|
113
|
+
const component = {
|
|
114
|
+
render(width: number): string[] {
|
|
115
|
+
return [`raw#${width}`];
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
const release = suppressBuiltInFooter(component);
|
|
119
|
+
assert.equal(typeof release, "function");
|
|
120
|
+
assert.deepEqual(component.render(3), []);
|
|
121
|
+
|
|
122
|
+
release();
|
|
123
|
+
assert.deepEqual(component.render(3), ["raw#3"]);
|
|
124
|
+
assert.equal(Object.prototype.hasOwnProperty.call(component, "render"), true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("原型只读时不抛错,退回原样", () => {
|
|
128
|
+
class Locked {
|
|
129
|
+
render(): string[] {
|
|
130
|
+
return ["locked"];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
Object.defineProperty(Locked.prototype, "render", {
|
|
134
|
+
configurable: false,
|
|
135
|
+
writable: false,
|
|
136
|
+
value: () => ["locked"],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
assert.equal(suppressBuiltInFooter(Locked), undefined);
|
|
140
|
+
assert.deepEqual(new Locked().render(), ["locked"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("wrapper 的 this 透传给真原版(实例状态照常读写)", () => {
|
|
144
|
+
const footer = new FakeFooter();
|
|
145
|
+
const release = suppressBuiltInFooter(FakeFooter)!;
|
|
146
|
+
assert.deepEqual(footer.render(80), []);
|
|
147
|
+
release();
|
|
148
|
+
assert.deepEqual(footer.render(80), ["pi-default#80", "stats#80"]);
|
|
149
|
+
assert.equal(footer.originalRenders, 1);
|
|
150
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* footer-suppress.ts — 启动窗口内「关掉」pi 内置 footer
|
|
3
|
+
*
|
|
4
|
+
* 要解决的问题:pi 起 TUI 的顺序是 `ui.start()`(内置 footer 早已挂在 footer 容器里)→
|
|
5
|
+
* `ensureTool("fd"/"rg")` → `rebindCurrentSession()` → 才 emit `session_start`
|
|
6
|
+
* (`interactive-mode.js` 的 `init()`)。扩展拿到 ctx、能 `setFooter()` 只能等到最后一步,
|
|
7
|
+
* 而 Runner 的 `emit()` 是**串行 await** 每个扩展的 handler,排在 `statusline` 前面的扩展
|
|
8
|
+
* (本机是 `mcp`,握手要连 MCP server)还会再往后推一点。实测本机冷启动:内置 footer 在
|
|
9
|
+
* ~480ms 出第一帧,我们的 statusline 到 ~1.2s 才装上 —— 中间那 ~0.7s 底部是 pi 默认状态行
|
|
10
|
+
* (`~/path` + `0.0%/1.0M (auto) ... deepseek-flash • max`),然后整块换成 `⚡️ ...`。
|
|
11
|
+
* 用户看到的就是「先默认、后扩展」闪一下。
|
|
12
|
+
*
|
|
13
|
+
* 为什么只能在原型上解决:扩展能拿到 TUI 的最早时刻就是 `session_start`;但扩展的**模块求值 /
|
|
14
|
+
* 工厂**发生在 TUI 创建之前(扩展由 `createAgentSessionServices` → `resourceLoader.reload()`
|
|
15
|
+
* 加载,InteractiveMode 之后才 new)。所以这里在工厂里就把 `FooterComponent.prototype.render`
|
|
16
|
+
* 换掉:窗口内内置 footer 渲染 0 行(底部空着等我们的 statusline),而不是先画一个马上要变的
|
|
17
|
+
* 默认状态行。
|
|
18
|
+
*
|
|
19
|
+
* 为什么打得中:bundle 运行时,扩展解析 `@earendil-works/pi-coding-agent` 走 loader 的
|
|
20
|
+
* `virtualModules`(`dist/bundle/index.js`),与 `interactive-mode.js` 内部用的是同一个类对象
|
|
21
|
+
* (同一个 chunk)—— 和 `fenceless-code-block` 打 `Markdown.prototype` 是同一个前提;
|
|
22
|
+
* 万一打不中也只是退回原样(照旧闪一下),不会坏。
|
|
23
|
+
*
|
|
24
|
+
* 约束:
|
|
25
|
+
* - 只换 `prototype.render`,不碰实例、不碰 pi 内部状态,也不动 children;
|
|
26
|
+
* - 幂等且可解除:`/reload` 会重新求值模块,新实例先解开旧实例的接管再打自己的;
|
|
27
|
+
* - `maxAgeMs` 兜底:万一我们的 footer 始终没装上(扩展报错、非 TUI 模式),到点自动交还,
|
|
28
|
+
* 回到 pi 默认行为,不会让底部永远空着;
|
|
29
|
+
* - 任何一步抛错都当作「没打上」,绝不把异常漏给宿主。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** 挂在 footer 原型上的接管记录键(全局符号注册表:`/reload` 后新扩展实例也能看到旧实例的接管)。 */
|
|
33
|
+
export const FOOTER_SUPPRESS_KEY: symbol = Symbol.for("litellm-any.pi-statusline.footerSuppress");
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 兜底时长:正常 `session_start` 远早于它解除,只有交接失败才会走到这里。
|
|
37
|
+
* 取 30s 是因为我们排在别的扩展后面 —— `Runner.emit()` 串行 await,`mcp`(字母序在前)
|
|
38
|
+
* 只在握手全部结束后才轮到我们,握手本身的上限就是 20s。
|
|
39
|
+
*/
|
|
40
|
+
export const FOOTER_SUPPRESS_MAX_AGE_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
/** 本模块用到的 `FooterComponent` 形状(不 import pi,单测直接塞假类)。 */
|
|
43
|
+
export interface FooterClassLike {
|
|
44
|
+
prototype: { render(width: number): string[] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SuppressRecord {
|
|
48
|
+
release: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 接管记录挂在哪:优先 `target.prototype`(真正被改的那个对象),退而求其次用 target 自身。
|
|
53
|
+
* 传类或传原型都能用;形状对不上返回 undefined(那就什么都不做)。
|
|
54
|
+
*/
|
|
55
|
+
export function suppressTarget(target: unknown): { render(width: number): string[] } | undefined {
|
|
56
|
+
const kind = typeof target;
|
|
57
|
+
if (target === null || (kind !== "object" && kind !== "function")) return undefined;
|
|
58
|
+
for (const candidate of [(target as { prototype?: unknown }).prototype, target]) {
|
|
59
|
+
if (
|
|
60
|
+
candidate !== null &&
|
|
61
|
+
typeof candidate === "object" &&
|
|
62
|
+
typeof (candidate as { render?: unknown }).render === "function"
|
|
63
|
+
) {
|
|
64
|
+
return candidate as { render(width: number): string[] };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 解除 `target` 上现有的接管(可能是别的扩展实例遗留的)。返回是否解掉了什么。 */
|
|
71
|
+
export function releaseFooterSuppressor(target: unknown): boolean {
|
|
72
|
+
try {
|
|
73
|
+
const holder = suppressTarget(target) as { [FOOTER_SUPPRESS_KEY]?: SuppressRecord } | undefined;
|
|
74
|
+
const record = holder?.[FOOTER_SUPPRESS_KEY];
|
|
75
|
+
if (!record || typeof record.release !== "function") return false;
|
|
76
|
+
record.release();
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 把 `footerClass.prototype.render` 换成「返回 0 行」,返回解除函数(幂等)。
|
|
85
|
+
*
|
|
86
|
+
* 解除(或超过 `maxAgeMs`)后原型恢复原样,渲染透传;重复接管会先交还上一次,不会叠 wrapper。
|
|
87
|
+
* 形状不对 / 原型只读时返回 undefined(调用方按「没打上」处理,行为退回原样)。
|
|
88
|
+
*/
|
|
89
|
+
export function suppressBuiltInFooter(
|
|
90
|
+
footerClass: unknown,
|
|
91
|
+
options: { maxAgeMs?: number; now?: () => number } = {},
|
|
92
|
+
): (() => void) | undefined {
|
|
93
|
+
const target = suppressTarget(footerClass);
|
|
94
|
+
if (!target) return undefined;
|
|
95
|
+
// 先解除已有接管(自己重入 / 上一个扩展实例遗留),拿到的才是真原版。
|
|
96
|
+
releaseFooterSuppressor(target);
|
|
97
|
+
|
|
98
|
+
const originalRender = target.render;
|
|
99
|
+
const maxAgeMs = options.maxAgeMs ?? FOOTER_SUPPRESS_MAX_AGE_MS;
|
|
100
|
+
const now = options.now ?? Date.now;
|
|
101
|
+
const armedAt = now();
|
|
102
|
+
const hadOwnRender = Object.prototype.hasOwnProperty.call(target, "render");
|
|
103
|
+
const state: { released: boolean; record?: SuppressRecord } = { released: false };
|
|
104
|
+
|
|
105
|
+
const release = (): void => {
|
|
106
|
+
if (state.released) return;
|
|
107
|
+
state.released = true;
|
|
108
|
+
try {
|
|
109
|
+
const holder = target as { [FOOTER_SUPPRESS_KEY]?: SuppressRecord };
|
|
110
|
+
if (state.record && holder[FOOTER_SUPPRESS_KEY] === state.record) delete holder[FOOTER_SUPPRESS_KEY];
|
|
111
|
+
if (hadOwnRender) target.render = originalRender;
|
|
112
|
+
else delete (target as { render?: unknown }).render;
|
|
113
|
+
} catch {
|
|
114
|
+
// 原型只读 / 已被换掉:wrapper 自己会因 released 标志透传,不再屏蔽。
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const wrapper = function (this: unknown, width: number): string[] {
|
|
119
|
+
if (state.released) return originalRender.call(this, width);
|
|
120
|
+
if (now() - armedAt > maxAgeMs) {
|
|
121
|
+
release();
|
|
122
|
+
return originalRender.call(this, width);
|
|
123
|
+
}
|
|
124
|
+
return [];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
target.render = wrapper;
|
|
129
|
+
state.record = { release };
|
|
130
|
+
(target as { [FOOTER_SUPPRESS_KEY]?: SuppressRecord })[FOOTER_SUPPRESS_KEY] = state.record;
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return release;
|
|
135
|
+
}
|
|
@@ -31,12 +31,18 @@
|
|
|
31
31
|
* 清掉所有 `setStatus`,新会话 `session_start` 才让我们重装 —— 中间那几十毫秒会真的出帧,
|
|
32
32
|
* 于是底部「闪」一下。本扩展用 `footer-guard.ts` 在渲染路径上把这段窗口冻住(重放上一帧的
|
|
33
33
|
* 行),原因与约束见那个文件的头注释;关掉它只需把 `PI_STATUSLINE_FREEZE` 设为 `off`。
|
|
34
|
+
*
|
|
35
|
+
* 启动那一段(进程刚起来、`session_start` 还没轮到我们)走的是另一条路:`footer-suppress.ts`
|
|
36
|
+
* 在扩展工厂里就把 `FooterComponent.prototype.render` 换成返回 0 行,内置 statusline 一帧
|
|
37
|
+
* 都不出现(原因、时机与兜底见那个文件的头注释)。它由 `PI_STATUSLINE_BOOT_SUPPRESS=off` 关闭。
|
|
34
38
|
*/
|
|
35
39
|
|
|
40
|
+
import { FooterComponent as PiFooterComponent } from "@earendil-works/pi-coding-agent";
|
|
36
41
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
42
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
38
43
|
import { readGitDiffStat } from "./git.ts";
|
|
39
44
|
import { findRenderContainer, freezeFooterContainer, releaseFooterGuard } from "./footer-guard.ts";
|
|
45
|
+
import { suppressBuiltInFooter } from "./footer-suppress.ts";
|
|
40
46
|
import {
|
|
41
47
|
STATUSLINE_KEY,
|
|
42
48
|
ELLIPSIS,
|
|
@@ -47,6 +53,7 @@ import {
|
|
|
47
53
|
const GIT_POLL_INTERVAL_MS = 30_000;
|
|
48
54
|
const GIT_DEBOUNCE_MS = 400;
|
|
49
55
|
const FROZEN_DISABLED = (process.env.PI_STATUSLINE_FREEZE ?? "").trim().toLowerCase() === "off";
|
|
56
|
+
const BOOT_SUPPRESS_DISABLED = (process.env.PI_STATUSLINE_BOOT_SUPPRESS ?? "").trim().toLowerCase() === "off";
|
|
50
57
|
|
|
51
58
|
/** footer 容器要的是这个组件对象;只要 render/dispose 形状,便于在守卫里按身份比对。 */
|
|
52
59
|
interface FooterComponent {
|
|
@@ -56,6 +63,11 @@ interface FooterComponent {
|
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
export default function statusline(pi: ExtensionAPI) {
|
|
66
|
+
// 工厂在 TUI 创建之前跑,所以这里就能把内置 footer 静音:窗口内它渲染 0 行,
|
|
67
|
+
// 等下面 `installFooter` 把我们的组件挂上去再交还(见 footer-suppress.ts)。
|
|
68
|
+
const releaseBuiltInFooter: (() => void) | undefined = BOOT_SUPPRESS_DISABLED
|
|
69
|
+
? undefined
|
|
70
|
+
: suppressBuiltInFooter(PiFooterComponent);
|
|
59
71
|
let generation = 0;
|
|
60
72
|
let activeTarget: { cwd: string; generation: number } | undefined;
|
|
61
73
|
let requestRender: (() => void) | undefined;
|
|
@@ -155,10 +167,15 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
155
167
|
ctx.ui.setStatus(STATUSLINE_KEY, undefined);
|
|
156
168
|
if (!activeTarget) {
|
|
157
169
|
requestRender = undefined;
|
|
170
|
+
// print / rpc 模式没有 footer,不会有人来装,直接交还内置那只。
|
|
171
|
+
releaseBuiltInFooter?.();
|
|
158
172
|
return;
|
|
159
173
|
}
|
|
160
174
|
const target = activeTarget;
|
|
161
175
|
|
|
176
|
+
// 挂上我们的组件前先交还内置那只:两条语句之间是同一个同步块,出不了帧。
|
|
177
|
+
// 放在 `setFooter` 之前(而不是之后)是为了万一它抛错也不会让底部空着。
|
|
178
|
+
releaseBuiltInFooter?.();
|
|
162
179
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
163
180
|
requestRender = () => tui.requestRender();
|
|
164
181
|
tuiHandle = tui;
|