pi-zh-cn 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 ADDED
@@ -0,0 +1,58 @@
1
+ # pi-zh-cn
2
+
3
+ pi([@earendil-works/pi-coding-agent](https://github.com/earendil-works/pi))简体中文 TUI 界面汉化插件。
4
+ 纯扩展 API 实现,不 patch 源码、不侵入安装;正常情况下可随 pi 升级保持兼容。
5
+
6
+ ## 汉化范围
7
+
8
+ | 界面元素 | 方式 |
9
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
10
+ | 启动 header(标题 + 快捷键提示行 + 引导语) | `ctx.ui.setHeader()`,快捷键跟随用户键位配置 |
11
+ | 流式加载提示(默认 "Working...") | `ctx.ui.setWorkingMessage()` → 「正在处理…」 |
12
+ | thinking 折叠块标签 | `ctx.ui.setHiddenThinkingLabel()` → 「思考过程已折叠」 |
13
+ | 底部状态栏 | `ctx.ui.setFooter()` 中文重建:目录/分支/会话名、token/缓存/费用/上下文用量、模型/思考等级、扩展状态行 |
14
+ | read 工具渲染 | 同名重注册,标签「读取」+ 中文状态文案 |
15
+ | bash 工具渲染 | 同名重注册,标签「执行」+「完成 / 退出码 N / 输出过长已截断」 |
16
+ | edit 工具渲染 | 同名重注册,标签「编辑」+「已修改 / +N / -N」 |
17
+ | write 工具渲染 | 同名重注册,标签「写入」+「已写入」 |
18
+ | grep 工具渲染 | 同名重注册,标签「搜索内容」+ 中文搜索结果状态 |
19
+ | find 工具渲染 | 同名重注册,标签「查找文件」+ 中文查找结果状态 |
20
+ | ls 工具渲染 | 同名重注册,标签「列出目录」+ 中文目录状态 |
21
+ | PowerShell 工具渲染 | 同名重注册,标签「执行 PowerShell」+ 中文执行状态 |
22
+
23
+ **明确不改**:工具 description(发给模型的内容保持英文,避免影响模型行为)、
24
+ 内置菜单及其他核心 chrome(扩展 API 覆盖不到,见下方“已知边界”)。
25
+
26
+ 运行时可用 `/footer` 在中文 footer 和 built-in footer 之间切换,方便核对统计数据。
27
+
28
+ 插件只替换 pi 已提供工具的渲染,不改变原有 active 工具集合;`grep`、`find`、`ls` 或 `powershell` 未启用时,不会因安装本插件而自动启用。
29
+
30
+ ## 安装
31
+
32
+ ```bash
33
+ # 本地路径安装(路径引用,不复制文件)
34
+ pi install /path/to/pi-zh
35
+
36
+ # 或发布 npm 后
37
+ pi install npm:pi-zh-cn
38
+
39
+ # 查看已安装包(remove 时使用这里显示的 source)
40
+ pi list
41
+ ```
42
+
43
+ ## 已知边界
44
+
45
+ - pi 无内置 i18n,内置菜单、`esc to …` 提示行等硬编码英文文案无法通过扩展 API 修改。
46
+ 若需彻底汉化,需要 fork 上游或 patch `dist/bundle`(维护成本高,本插件不做)。
47
+
48
+ ## 开发
49
+
50
+ ```bash
51
+ npm install # 安装 devDependencies(含 peer 自动安装)
52
+ npm exec tsc -p tsconfig.json # 类型检查
53
+
54
+ # 本地试跑
55
+ pi --no-session -e ./extensions/index.ts -p "hi"
56
+ ```
57
+
58
+ 文案集中在 [`extensions/i18n.ts`](extensions/i18n.ts),改词只需动这一个文件。
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 汉化文案表
3
+ *
4
+ * 只包含通过扩展 API 能覆盖的界面文案。
5
+ * 工具 description(发给模型的)保持英文原文,避免影响模型行为。
6
+ */
7
+
8
+ export const zh = {
9
+ // 流式加载提示(默认 "Working...",工具执行时也会显示)
10
+ workingMessage: "正在处理…",
11
+
12
+ // thinking 折叠块标签(默认 "Thinking...")
13
+ hiddenThinkingLabel: "思考过程已折叠",
14
+
15
+ // footer
16
+ footer: {
17
+ cwdPrefix: "当前目录:",
18
+ branchPrefix: "当前分支:",
19
+ inputLabel: "输入",
20
+ outputLabel: "输出",
21
+ ratioLabel: "占比",
22
+ cacheReadLabel: "缓存读",
23
+ cacheWriteLabel: "缓存写",
24
+ cacheHitLabel: "缓存命中率",
25
+ costLabel: "费用",
26
+ noModel: "尚未选择模型",
27
+ thinkingOff: "思考已关闭",
28
+ thinkingLevels: {
29
+ off: "关闭",
30
+ minimal: "最低",
31
+ low: "低",
32
+ medium: "中",
33
+ high: "高",
34
+ xhigh: "超高",
35
+ max: "最高",
36
+ },
37
+ autoCompactSuffix: "(自动压缩)",
38
+ subSuffix: "(订阅)",
39
+ },
40
+
41
+ // 启动 header
42
+ header: {
43
+ // 折叠态提示行(与内置一致:" · " 分隔)
44
+ interrupt: "中断",
45
+ clearExit: "清空/退出",
46
+ commands: "命令菜单",
47
+ onboarding:
48
+ "Pi 能解释自身的功能、查阅自己的文档——想知道怎么用、怎么扩展,直接问它。",
49
+ },
50
+
51
+ // 内置工具渲染
52
+ tools: {
53
+ read: {
54
+ label: "读取",
55
+ title: "读取",
56
+ reading: "读取中…",
57
+ imageLoaded: "图片已加载",
58
+ noContent: "无内容",
59
+ lines: (n: number) => `${n} 行`,
60
+ truncatedFrom: (n: number) => `(共 ${n} 行,仅显示部分)`,
61
+ },
62
+ bash: {
63
+ label: "执行",
64
+ title: "$ ",
65
+ timeout: (s: number) => `(超时:${s}s)`,
66
+ running: "运行中…",
67
+ done: "完成",
68
+ failed: "执行失败",
69
+ exit: (code: number) => `退出码 ${code}`,
70
+ lines: (n: number) => `${n} 行输出`,
71
+ truncated: "[输出过长,已截断]",
72
+ },
73
+ edit: {
74
+ label: "编辑",
75
+ title: "编辑 ",
76
+ editing: "修改中…",
77
+ applied: "已修改",
78
+ additions: (n: number) => `+${n}`,
79
+ removals: (n: number) => `-${n}`,
80
+ },
81
+ write: {
82
+ label: "写入",
83
+ title: "写入 ",
84
+ writing: "写入中…",
85
+ written: "已写入",
86
+ lines: (n: number) => `${n} 行`,
87
+ },
88
+ grep: {
89
+ label: "搜索内容",
90
+ title: "搜索内容 ",
91
+ searching: "搜索中…",
92
+ noMatches: "未找到匹配内容",
93
+ matches: (n: number) => `${n} 行结果`,
94
+ matchLimit: (n: number) => `(达到 ${n} 条匹配上限)`,
95
+ truncated: "(部分行已截断)",
96
+ },
97
+ find: {
98
+ label: "查找文件",
99
+ title: "查找文件 ",
100
+ searching: "查找中…",
101
+ noFiles: "未找到匹配文件",
102
+ results: (n: number) => `${n} 个结果`,
103
+ resultLimit: (n: number) => `(达到 ${n} 个结果上限)`,
104
+ },
105
+ ls: {
106
+ label: "列出目录",
107
+ title: "列出目录 ",
108
+ listing: "读取目录中…",
109
+ empty: "目录为空",
110
+ entries: (n: number) => `${n} 项`,
111
+ entryLimit: (n: number) => `(达到 ${n} 项上限)`,
112
+ },
113
+ powershell: {
114
+ label: "执行 PowerShell",
115
+ title: "PS> ",
116
+ timeout: (s: number) => `(超时:${s}s)`,
117
+ running: "PowerShell 运行中…",
118
+ done: "完成",
119
+ failed: "执行失败",
120
+ exit: (code: number) => `退出码 ${code}`,
121
+ lines: (n: number) => `${n} 行输出`,
122
+ truncated: "[输出过长,已截断]",
123
+ },
124
+ },
125
+ } as const;
126
+
127
+ export type Zh = typeof zh;
@@ -0,0 +1,819 @@
1
+ /**
2
+ * pi-zh-cn —— pi 简体中文 TUI 界面汉化扩展
3
+ *
4
+ * 覆盖范围(扩展 API 所及):
5
+ * - 流式加载提示(setWorkingMessage)
6
+ * - thinking 折叠块标签(setHiddenThinkingLabel)
7
+ * - 启动 header(setHeader)
8
+ * - 底部状态栏(setFooter)
9
+ * - 内置 read/bash/edit/write 工具的渲染文案(同名重注册,执行委托原实现)
10
+ *
11
+ * 不改动发给模型的内容(工具 description 保持英文)。
12
+ */
13
+
14
+ import type {
15
+ BashToolDetails,
16
+ EditToolDetails,
17
+ ExtensionAPI,
18
+ FindToolDetails,
19
+ GrepToolDetails,
20
+ LsToolDetails,
21
+ PowerShellToolDetails,
22
+ ReadToolDetails,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ CONFIG_DIR_NAME,
26
+ createBashTool,
27
+ createEditTool,
28
+ createFindTool,
29
+ createGrepTool,
30
+ createLsTool,
31
+ createPowerShellTool,
32
+ createReadTool,
33
+ createWriteTool,
34
+ getAgentDir,
35
+ keyHint,
36
+ keyText,
37
+ rawKeyHint,
38
+ VERSION,
39
+ } from "@earendil-works/pi-coding-agent";
40
+ import { homedir } from "node:os";
41
+ import { readFileSync } from "node:fs";
42
+ import { join, relative } from "node:path";
43
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
44
+ import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
+ import { zh } from "./i18n.ts";
46
+
47
+ export default function (pi: ExtensionAPI) {
48
+ let toggleFooter: (() => void) | undefined;
49
+
50
+ // 只注册一次;当前 session 的 footer 实现由 session_start 更新。
51
+ pi.registerCommand("footer", {
52
+ description: "在中文 footer 和内置 footer 之间切换",
53
+ handler: async () => {
54
+ toggleFooter?.();
55
+ },
56
+ });
57
+
58
+ // ---------- UI 文案 + 中文 header/footer(session 启动时应用) ----------
59
+ pi.on("session_start", (_event, ctx) => {
60
+ if (!ctx.hasUI) return;
61
+
62
+ ctx.ui.setWorkingMessage(zh.workingMessage);
63
+ ctx.ui.setHiddenThinkingLabel(zh.hiddenThinkingLabel);
64
+
65
+ // 中文 header:复刻内置结构(标题 + 折叠提示行 + 引导语),快捷键跟随用户键位配置。
66
+ if (ctx.mode === "tui") {
67
+ ctx.ui.setHeader((_tui, theme) => ({
68
+ render(_width: number): string[] {
69
+ const logo =
70
+ theme.bold(theme.fg("accent", "pi")) +
71
+ theme.fg("dim", ` v${VERSION}`);
72
+ const compact = [
73
+ keyHint("app.interrupt", zh.header.interrupt),
74
+ rawKeyHint(
75
+ `${keyText("app.clear")}/${keyText("app.exit")}`,
76
+ zh.header.clearExit,
77
+ ),
78
+ rawKeyHint("/", zh.header.commands),
79
+ rawKeyHint("!", "bash"),
80
+ ].join(theme.fg("muted", " · "));
81
+ return [logo, compact, "", theme.fg("dim", zh.header.onboarding)];
82
+ },
83
+ invalidate() {},
84
+ }));
85
+ }
86
+
87
+ // 中文 footer:逐行复刻内置信息(pwd 行 / 统计行 / 扩展状态行),仅描述文案用中文。
88
+ if (ctx.mode === "tui") {
89
+ let usingZhFooter = true;
90
+
91
+ const applyZhFooter = () => {
92
+ usingZhFooter = true;
93
+ // 自动压缩标记:全局设置为基准,受信任项目的本地设置优先。
94
+ // 一秒缓存避免流式渲染时频繁读文件,同时能跟随 /settings 的运行时修改。
95
+ const settingsPaths = [join(getAgentDir(), "settings.json")];
96
+ if (ctx.isProjectTrusted())
97
+ settingsPaths.push(join(ctx.cwd, CONFIG_DIR_NAME, "settings.json"));
98
+ let autoCompactEnabled = true;
99
+ let autoCompactCheckedAt = 0;
100
+ const getAutoCompactSuffix = () => {
101
+ const now = Date.now();
102
+ if (now - autoCompactCheckedAt >= 1000) {
103
+ autoCompactEnabled = true;
104
+ for (const settingsPath of settingsPaths) {
105
+ try {
106
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
107
+ if (typeof settings?.compaction?.enabled === "boolean") {
108
+ autoCompactEnabled = settings.compaction.enabled;
109
+ }
110
+ } catch {
111
+ // 设置文件不存在或内容无效时忽略该层。
112
+ }
113
+ }
114
+ autoCompactCheckedAt = now;
115
+ }
116
+ return autoCompactEnabled ? zh.footer.autoCompactSuffix : "";
117
+ };
118
+
119
+ let unsubscribeBranch: (() => void) | undefined;
120
+
121
+ ctx.ui.setFooter((tui, theme, footerData) => {
122
+ unsubscribeBranch?.();
123
+ unsubscribeBranch = footerData.onBranchChange(() =>
124
+ tui.requestRender(),
125
+ );
126
+
127
+ const home = homedir();
128
+ return {
129
+ dispose() {
130
+ unsubscribeBranch?.();
131
+ unsubscribeBranch = undefined;
132
+ },
133
+ invalidate() {},
134
+ render(width: number): string[] {
135
+ let input = 0,
136
+ output = 0,
137
+ cacheRead = 0,
138
+ cacheWrite = 0,
139
+ cost = 0;
140
+ const addUsage = (t: {
141
+ input: number;
142
+ output: number;
143
+ cacheRead: number;
144
+ cacheWrite: number;
145
+ cost: { total: number };
146
+ }) => {
147
+ input += t.input;
148
+ output += t.output;
149
+ cacheRead += t.cacheRead;
150
+ cacheWrite += t.cacheWrite;
151
+ cost += t.cost.total;
152
+ };
153
+ let latestRate: number | undefined;
154
+
155
+ for (const e of ctx.sessionManager.getEntries()) {
156
+ if (e.type === "message" && e.message.role === "assistant") {
157
+ const u = (e.message as AssistantMessage).usage;
158
+ addUsage(u);
159
+ const promptTokens = u.input + u.cacheRead + u.cacheWrite;
160
+ latestRate =
161
+ promptTokens > 0
162
+ ? (u.cacheRead / promptTokens) * 100
163
+ : undefined;
164
+ } else if (
165
+ e.type === "message" &&
166
+ e.message.role === "toolResult" &&
167
+ e.message.usage
168
+ ) {
169
+ addUsage(e.message.usage as any);
170
+ } else if (
171
+ (e.type === "branch_summary" || e.type === "compaction") &&
172
+ (e as any).usage
173
+ ) {
174
+ addUsage((e as any).usage);
175
+ }
176
+ }
177
+
178
+ const fmt = (n: number) =>
179
+ n < 1e3
180
+ ? `${n}`
181
+ : n < 1e4
182
+ ? `${(n / 1e3).toFixed(1)}k`
183
+ : n < 1e6
184
+ ? `${Math.round(n / 1e3)}k`
185
+ : n < 1e7
186
+ ? `${(n / 1e6).toFixed(1)}M`
187
+ : `${Math.round(n / 1e6)}M`;
188
+
189
+ // 第 1 行:当前目录:~路径(分支)• 会话名
190
+ let pwd = ctx.sessionManager.getCwd();
191
+ const homeRelative = relative(home, pwd);
192
+ if (homeRelative === "") pwd = "~";
193
+ else if (
194
+ homeRelative &&
195
+ !homeRelative.startsWith("..") &&
196
+ !homeRelative.startsWith("/")
197
+ )
198
+ pwd = `~/${homeRelative}`;
199
+ const branch = footerData.getGitBranch();
200
+ if (branch) pwd += ` ${zh.footer.branchPrefix}(${branch})`;
201
+ const sessionName = ctx.sessionManager.getSessionName();
202
+ if (sessionName) pwd += ` • ${sessionName}`;
203
+ pwd = `${zh.footer.cwdPrefix}${pwd}`;
204
+
205
+ // 第 2 行左:严格按内置顺序:输入、输出、缓存、费用、上下文占比。
206
+ const clauses: string[] = [];
207
+ if (input > 0 || output > 0) {
208
+ const io: string[] = [];
209
+ if (input > 0) io.push(`${zh.footer.inputLabel}↑${fmt(input)}`);
210
+ if (output > 0)
211
+ io.push(`${zh.footer.outputLabel}↓${fmt(output)}`);
212
+ clauses.push(io.join("/"));
213
+ }
214
+
215
+ const cacheParts: string[] = [];
216
+ if (cacheRead > 0)
217
+ cacheParts.push(`${zh.footer.cacheReadLabel}${fmt(cacheRead)}`);
218
+ if (cacheWrite > 0)
219
+ cacheParts.push(
220
+ `${zh.footer.cacheWriteLabel}${fmt(cacheWrite)}`,
221
+ );
222
+ if (
223
+ (cacheRead > 0 || cacheWrite > 0) &&
224
+ latestRate !== undefined
225
+ ) {
226
+ cacheParts.push(
227
+ `${zh.footer.cacheHitLabel}${latestRate.toFixed(1)}%`,
228
+ );
229
+ }
230
+ if (cacheParts.length > 0) clauses.push(cacheParts.join(" "));
231
+
232
+ const provider = ctx.model
233
+ ? ctx.modelRegistry.getProvider(ctx.model.provider)
234
+ : undefined;
235
+ const usingSubscription =
236
+ ctx.model?.provider === "kimi-coding" ||
237
+ (!!ctx.model &&
238
+ ctx.modelRegistry.isUsingOAuth(ctx.model) &&
239
+ provider?.auth.oauth?.isSubscription === true);
240
+ if (cost > 0 || usingSubscription) {
241
+ clauses.push(
242
+ `${zh.footer.costLabel}$${cost.toFixed(3)}${usingSubscription ? zh.footer.subSuffix : ""}`,
243
+ );
244
+ }
245
+
246
+ // built-in 顺序的最后一项:上下文占比 / 上下文窗口(自动压缩)。
247
+ const cu = ctx.getContextUsage();
248
+ const windowSize =
249
+ cu?.contextWindow ?? ctx.model?.contextWindow ?? 0;
250
+ const pctValue = cu?.percent ?? 0;
251
+ // 与 built-in 一致:明确为 null 时显示未知;尚无 usage 时按 0.0% 显示。
252
+ const pctText =
253
+ cu?.percent === null ? "?" : `${pctValue.toFixed(1)}%`;
254
+ const contextClause = `${zh.footer.ratioLabel}${pctText}/${fmt(windowSize)}${getAutoCompactSuffix()}`;
255
+ const contextIndex = clauses.length;
256
+ clauses.push(contextClause);
257
+ const xpIndex =
258
+ process.env.PI_EXPERIMENTAL === "1" ? clauses.length : -1;
259
+ if (xpIndex >= 0) clauses.push("xp");
260
+
261
+ // 占比按阈值变色(>90% 红、>70% 黄);实验标记沿用 built-in 样式。
262
+ const statsLeft = clauses
263
+ .map((clause, index) => {
264
+ if (index === contextIndex) {
265
+ return pctValue > 90
266
+ ? theme.fg("error", clause)
267
+ : pctValue > 70
268
+ ? theme.fg("warning", clause)
269
+ : theme.fg("dim", clause);
270
+ }
271
+ if (index === xpIndex) {
272
+ return `${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`;
273
+ }
274
+ return theme.fg("dim", clause);
275
+ })
276
+ .join(" ");
277
+
278
+ // 第 2 行右:provider • 模型 • 思考等级(等级映射为中文)
279
+ let rightSideWithoutProvider = ctx.model?.id ?? zh.footer.noModel;
280
+ if (ctx.model?.reasoning) {
281
+ const tl = ctx.thinkingLevel || "off";
282
+ const tlLabel =
283
+ tl === "off"
284
+ ? zh.footer.thinkingOff
285
+ : ((zh.footer.thinkingLevels as Record<string, string>)[
286
+ tl
287
+ ] ?? tl);
288
+ rightSideWithoutProvider = `${rightSideWithoutProvider} • ${tlLabel}`;
289
+ }
290
+ let rightSide = rightSideWithoutProvider;
291
+ if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
292
+ rightSide = `(${ctx.model.provider}) ${rightSideWithoutProvider}`;
293
+ if (
294
+ visibleWidth(statsLeft) + 2 + visibleWidth(rightSide) >
295
+ width
296
+ ) {
297
+ rightSide = rightSideWithoutProvider;
298
+ }
299
+ }
300
+
301
+ let statsDisplay = statsLeft;
302
+ let statsWidth = visibleWidth(statsDisplay);
303
+ if (statsWidth > width) {
304
+ statsDisplay = truncateToWidth(statsDisplay, width, "...");
305
+ statsWidth = visibleWidth(statsDisplay);
306
+ }
307
+ const availableForRight = width - statsWidth - 2;
308
+ let statsLine = statsDisplay;
309
+ if (availableForRight > 0) {
310
+ const truncatedRight = truncateToWidth(
311
+ rightSide,
312
+ availableForRight,
313
+ "",
314
+ );
315
+ const padding = " ".repeat(
316
+ Math.max(
317
+ 0,
318
+ width - statsWidth - visibleWidth(truncatedRight),
319
+ ),
320
+ );
321
+ statsLine = `${statsDisplay}${padding}${theme.fg("dim", truncatedRight)}`;
322
+ }
323
+
324
+ const lines = [
325
+ truncateToWidth(
326
+ theme.fg("dim", pwd),
327
+ width,
328
+ theme.fg("dim", "..."),
329
+ ),
330
+ ];
331
+ lines.push(statsLine);
332
+
333
+ // 第 3 行:各扩展的 setStatus 状态
334
+ const statuses = footerData.getExtensionStatuses();
335
+ if (statuses.size > 0) {
336
+ const statusLine = Array.from(statuses.entries())
337
+ .sort(([a], [b]) => a.localeCompare(b))
338
+ .map(([, text]) =>
339
+ text
340
+ .replace(/[\r\n\t]/g, " ")
341
+ .replace(/ +/g, " ")
342
+ .trim(),
343
+ )
344
+ .join(" ");
345
+ lines.push(
346
+ truncateToWidth(statusLine, width, theme.fg("dim", "...")),
347
+ );
348
+ }
349
+ return lines;
350
+ },
351
+ };
352
+ });
353
+ };
354
+
355
+ applyZhFooter();
356
+ toggleFooter = () => {
357
+ if (usingZhFooter) {
358
+ usingZhFooter = false;
359
+ ctx.ui.setFooter(undefined);
360
+ } else {
361
+ applyZhFooter();
362
+ }
363
+ };
364
+ }
365
+
366
+ registerLocalizedTools(ctx.cwd);
367
+ });
368
+
369
+ function registerLocalizedTools(cwd: string) {
370
+ const activeTools = pi.getActiveTools();
371
+ const builtinTools = new Set(
372
+ pi
373
+ .getAllTools()
374
+ .filter((tool) => tool.sourceInfo.source === "builtin")
375
+ .map((tool) => tool.name),
376
+ );
377
+
378
+ // ---------- 内置工具汉化渲染 ----------
379
+ // read
380
+ const originalRead = createReadTool(cwd);
381
+ pi.registerTool({
382
+ ...originalRead,
383
+ label: zh.tools.read.label,
384
+
385
+ renderCall(args: any, theme: any, _context: any) {
386
+ let text = theme.fg("toolTitle", theme.bold(`${zh.tools.read.title} `));
387
+ text += theme.fg("accent", args.path);
388
+ if (args.offset || args.limit) {
389
+ const parts: string[] = [];
390
+ if (args.offset) parts.push(`offset=${args.offset}`);
391
+ if (args.limit) parts.push(`limit=${args.limit}`);
392
+ text += theme.fg("dim", ` (${parts.join(", ")})`);
393
+ }
394
+ return new Text(text, 0, 0);
395
+ },
396
+
397
+ renderResult(
398
+ result: any,
399
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
400
+ theme: any,
401
+ _context: any,
402
+ ) {
403
+ if (isPartial)
404
+ return new Text(theme.fg("warning", zh.tools.read.reading), 0, 0);
405
+
406
+ const details = result.details as ReadToolDetails | undefined;
407
+ const content = result.content[0];
408
+
409
+ if (content?.type === "image") {
410
+ return new Text(theme.fg("success", zh.tools.read.imageLoaded), 0, 0);
411
+ }
412
+ if (content?.type !== "text") {
413
+ return new Text(theme.fg("error", zh.tools.read.noContent), 0, 0);
414
+ }
415
+
416
+ const lineCount = content.text.split("\n").length;
417
+ let text = theme.fg("success", zh.tools.read.lines(lineCount));
418
+
419
+ if (details?.truncation?.truncated) {
420
+ text += theme.fg(
421
+ "warning",
422
+ zh.tools.read.truncatedFrom(details.truncation.totalLines),
423
+ );
424
+ }
425
+
426
+ if (expanded) {
427
+ for (const line of content.text.split("\n")) {
428
+ text += `\n${theme.fg("dim", line)}`;
429
+ }
430
+ }
431
+
432
+ return new Text(text, 0, 0);
433
+ },
434
+ });
435
+
436
+ // bash
437
+ const originalBash = createBashTool(cwd);
438
+ pi.registerTool({
439
+ ...originalBash,
440
+ label: zh.tools.bash.label,
441
+
442
+ renderCall(args: any, theme: any, _context: any) {
443
+ let text = theme.fg("toolTitle", theme.bold(zh.tools.bash.title));
444
+ const cmd: string =
445
+ args.command.length > 80
446
+ ? `${args.command.slice(0, 77)}...`
447
+ : args.command;
448
+ text += theme.fg("accent", cmd);
449
+ if (args.timeout) {
450
+ text += theme.fg("dim", zh.tools.bash.timeout(args.timeout));
451
+ }
452
+ return new Text(text, 0, 0);
453
+ },
454
+
455
+ renderResult(
456
+ result: any,
457
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
458
+ theme: any,
459
+ _context: any,
460
+ ) {
461
+ if (isPartial)
462
+ return new Text(theme.fg("warning", zh.tools.bash.running), 0, 0);
463
+
464
+ const details = result.details as BashToolDetails | undefined;
465
+ const content = result.content[0];
466
+ const output: string = content?.type === "text" ? content.text : "";
467
+
468
+ const exitMatch = output.match(
469
+ /(?:exit code:\s*|command exited with code\s+)(\d+)/i,
470
+ );
471
+ const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : null;
472
+ const failed =
473
+ exitCode !== null ||
474
+ /^error\b/i.test(output) ||
475
+ /\n\ncommand (?:timed out|was aborted|failed)/i.test(output);
476
+ const lineCount = output.split("\n").filter((l) => l.trim()).length;
477
+
478
+ let text = "";
479
+ if (!failed) {
480
+ text += theme.fg("success", zh.tools.bash.done);
481
+ } else {
482
+ text += theme.fg(
483
+ "error",
484
+ exitCode !== null
485
+ ? zh.tools.bash.exit(exitCode)
486
+ : zh.tools.bash.failed,
487
+ );
488
+ }
489
+ text += theme.fg("dim", ` (${zh.tools.bash.lines(lineCount)})`);
490
+
491
+ if (details?.truncation?.truncated) {
492
+ text += theme.fg("warning", ` ${zh.tools.bash.truncated}`);
493
+ }
494
+
495
+ if (expanded) {
496
+ for (const line of output.split("\n")) {
497
+ text += `\n${theme.fg("dim", line)}`;
498
+ }
499
+ }
500
+
501
+ return new Text(text, 0, 0);
502
+ },
503
+ });
504
+
505
+ // edit
506
+ const originalEdit = createEditTool(cwd);
507
+ pi.registerTool({
508
+ ...originalEdit,
509
+ label: zh.tools.edit.label,
510
+ renderShell: "self",
511
+
512
+ renderCall(args: any, theme: any, _context: any) {
513
+ let text = theme.fg("toolTitle", theme.bold(zh.tools.edit.title));
514
+ text += theme.fg("accent", args.path);
515
+ return new Text(text, 0, 0);
516
+ },
517
+
518
+ renderResult(
519
+ result: any,
520
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
521
+ theme: any,
522
+ _context: any,
523
+ ) {
524
+ if (isPartial)
525
+ return new Text(theme.fg("warning", zh.tools.edit.editing), 0, 0);
526
+
527
+ const details = result.details as EditToolDetails | undefined;
528
+ const content = result.content[0];
529
+
530
+ if (content?.type === "text" && content.text.startsWith("Error")) {
531
+ return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0);
532
+ }
533
+ if (!details?.diff) {
534
+ return new Text(theme.fg("success", zh.tools.edit.applied), 0, 0);
535
+ }
536
+
537
+ const diffLines: string[] = details.diff.split("\n");
538
+ let additions = 0,
539
+ removals = 0;
540
+ for (const line of diffLines) {
541
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++;
542
+ if (line.startsWith("-") && !line.startsWith("---")) removals++;
543
+ }
544
+
545
+ let text = theme.fg("success", zh.tools.edit.additions(additions));
546
+ text += theme.fg("dim", " / ");
547
+ text += theme.fg("error", zh.tools.edit.removals(removals));
548
+
549
+ if (expanded) {
550
+ for (const line of diffLines) {
551
+ text += `\n${theme.fg("dim", line)}`;
552
+ }
553
+ }
554
+
555
+ return new Text(text, 0, 0);
556
+ },
557
+ });
558
+
559
+ // write
560
+ const originalWrite = createWriteTool(cwd);
561
+ pi.registerTool({
562
+ ...originalWrite,
563
+ label: zh.tools.write.label,
564
+
565
+ renderCall(args: any, theme: any, _context: any) {
566
+ let text = theme.fg("toolTitle", theme.bold(zh.tools.write.title));
567
+ text += theme.fg("accent", args.path);
568
+ const lineCount = String(args.content).split("\n").length;
569
+ text += theme.fg("dim", ` (${zh.tools.write.lines(lineCount)})`);
570
+ return new Text(text, 0, 0);
571
+ },
572
+
573
+ renderResult(
574
+ result: any,
575
+ { isPartial }: { isPartial: boolean },
576
+ theme: any,
577
+ _context: any,
578
+ ) {
579
+ if (isPartial)
580
+ return new Text(theme.fg("warning", zh.tools.write.writing), 0, 0);
581
+
582
+ const content = result.content[0];
583
+ if (content?.type === "text" && content.text.startsWith("Error")) {
584
+ return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0);
585
+ }
586
+
587
+ return new Text(theme.fg("success", zh.tools.write.written), 0, 0);
588
+ },
589
+ });
590
+
591
+ if (builtinTools.has("grep")) {
592
+ const originalGrep = createGrepTool(cwd);
593
+ pi.registerTool({
594
+ ...originalGrep,
595
+ label: zh.tools.grep.label,
596
+
597
+ renderCall(args: any, theme: any, _context: any) {
598
+ let text = theme.fg("toolTitle", theme.bold(zh.tools.grep.title));
599
+ text += theme.fg("accent", args.pattern);
600
+ if (args.path) text += theme.fg("dim", ` · ${args.path}`);
601
+ if (args.glob) text += theme.fg("dim", ` (${args.glob})`);
602
+ return new Text(text, 0, 0);
603
+ },
604
+
605
+ renderResult(
606
+ result: any,
607
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
608
+ theme: any,
609
+ _context: any,
610
+ ) {
611
+ if (isPartial)
612
+ return new Text(theme.fg("warning", zh.tools.grep.searching), 0, 0);
613
+
614
+ const details = result.details as GrepToolDetails | undefined;
615
+ const content = result.content[0];
616
+ const output = content?.type === "text" ? content.text : "";
617
+ const noMatches = output === "No matches found";
618
+ const count = output
619
+ .split("\n")
620
+ .filter((line: string) => line.trim()).length;
621
+ let text = theme.fg(
622
+ noMatches ? "muted" : "success",
623
+ noMatches ? zh.tools.grep.noMatches : zh.tools.grep.matches(count),
624
+ );
625
+ if (details?.matchLimitReached !== undefined) {
626
+ text += theme.fg(
627
+ "warning",
628
+ zh.tools.grep.matchLimit(details.matchLimitReached),
629
+ );
630
+ }
631
+ if (details?.linesTruncated || details?.truncation?.truncated) {
632
+ text += theme.fg("warning", zh.tools.grep.truncated);
633
+ }
634
+ if (expanded && output) {
635
+ const expandedOutput = noMatches ? zh.tools.grep.noMatches : output;
636
+ text += `\n${theme.fg("dim", expandedOutput)}`;
637
+ }
638
+ return new Text(text, 0, 0);
639
+ },
640
+ });
641
+ }
642
+
643
+ if (builtinTools.has("find")) {
644
+ const originalFind = createFindTool(cwd);
645
+ pi.registerTool({
646
+ ...originalFind,
647
+ label: zh.tools.find.label,
648
+
649
+ renderCall(args: any, theme: any, _context: any) {
650
+ let text = theme.fg("toolTitle", theme.bold(zh.tools.find.title));
651
+ text += theme.fg("accent", args.pattern);
652
+ if (args.path) text += theme.fg("dim", ` · ${args.path}`);
653
+ return new Text(text, 0, 0);
654
+ },
655
+
656
+ renderResult(
657
+ result: any,
658
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
659
+ theme: any,
660
+ _context: any,
661
+ ) {
662
+ if (isPartial)
663
+ return new Text(theme.fg("warning", zh.tools.find.searching), 0, 0);
664
+
665
+ const details = result.details as FindToolDetails | undefined;
666
+ const content = result.content[0];
667
+ const output = content?.type === "text" ? content.text : "";
668
+ const noFiles = output === "No files found matching pattern";
669
+ const count = output
670
+ .split("\n")
671
+ .filter((line: string) => line.trim()).length;
672
+ let text = theme.fg(
673
+ noFiles ? "muted" : "success",
674
+ noFiles ? zh.tools.find.noFiles : zh.tools.find.results(count),
675
+ );
676
+ if (details?.resultLimitReached !== undefined) {
677
+ text += theme.fg(
678
+ "warning",
679
+ zh.tools.find.resultLimit(details.resultLimitReached),
680
+ );
681
+ }
682
+ if (details?.truncation?.truncated) {
683
+ text += theme.fg("warning", zh.tools.grep.truncated);
684
+ }
685
+ if (expanded && output) {
686
+ const expandedOutput = noFiles ? zh.tools.find.noFiles : output;
687
+ text += `\n${theme.fg("dim", expandedOutput)}`;
688
+ }
689
+ return new Text(text, 0, 0);
690
+ },
691
+ });
692
+ }
693
+
694
+ if (builtinTools.has("ls")) {
695
+ const originalLs = createLsTool(cwd);
696
+ pi.registerTool({
697
+ ...originalLs,
698
+ label: zh.tools.ls.label,
699
+
700
+ renderCall(args: any, theme: any, _context: any) {
701
+ let text = theme.fg("toolTitle", theme.bold(zh.tools.ls.title));
702
+ text += theme.fg("accent", args.path || ".");
703
+ return new Text(text, 0, 0);
704
+ },
705
+
706
+ renderResult(
707
+ result: any,
708
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
709
+ theme: any,
710
+ _context: any,
711
+ ) {
712
+ if (isPartial)
713
+ return new Text(theme.fg("warning", zh.tools.ls.listing), 0, 0);
714
+
715
+ const details = result.details as LsToolDetails | undefined;
716
+ const content = result.content[0];
717
+ const output = content?.type === "text" ? content.text : "";
718
+ const empty = output === "(empty directory)";
719
+ const count = output
720
+ .split("\n")
721
+ .filter((line: string) => line.trim()).length;
722
+ let text = theme.fg(
723
+ empty ? "muted" : "success",
724
+ empty ? zh.tools.ls.empty : zh.tools.ls.entries(count),
725
+ );
726
+ if (details?.entryLimitReached !== undefined) {
727
+ text += theme.fg(
728
+ "warning",
729
+ zh.tools.ls.entryLimit(details.entryLimitReached),
730
+ );
731
+ }
732
+ if (details?.truncation?.truncated) {
733
+ text += theme.fg("warning", zh.tools.grep.truncated);
734
+ }
735
+ if (expanded && output) {
736
+ const expandedOutput = empty ? zh.tools.ls.empty : output;
737
+ text += `\n${theme.fg("dim", expandedOutput)}`;
738
+ }
739
+ return new Text(text, 0, 0);
740
+ },
741
+ });
742
+ }
743
+
744
+ if (builtinTools.has("powershell")) {
745
+ const originalPowerShell = createPowerShellTool(cwd);
746
+ pi.registerTool({
747
+ ...originalPowerShell,
748
+ label: zh.tools.powershell.label,
749
+
750
+ renderCall(args: any, theme: any, _context: any) {
751
+ let text = theme.fg(
752
+ "toolTitle",
753
+ theme.bold(zh.tools.powershell.title),
754
+ );
755
+ const command =
756
+ args.command.length > 80
757
+ ? `${args.command.slice(0, 77)}...`
758
+ : args.command;
759
+ text += theme.fg("accent", command);
760
+ if (args.timeout) {
761
+ text += theme.fg("dim", zh.tools.powershell.timeout(args.timeout));
762
+ }
763
+ return new Text(text, 0, 0);
764
+ },
765
+
766
+ renderResult(
767
+ result: any,
768
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
769
+ theme: any,
770
+ _context: any,
771
+ ) {
772
+ if (isPartial)
773
+ return new Text(
774
+ theme.fg("warning", zh.tools.powershell.running),
775
+ 0,
776
+ 0,
777
+ );
778
+
779
+ const details = result.details as PowerShellToolDetails | undefined;
780
+ const content = result.content[0];
781
+ const output = content?.type === "text" ? content.text : "";
782
+ const exitMatch = output.match(
783
+ /(?:exit code:\s*|command exited with code\s+)(\d+)/i,
784
+ );
785
+ const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : null;
786
+ const failed =
787
+ exitCode !== null ||
788
+ /^error\b/i.test(output) ||
789
+ /only available on windows/i.test(output) ||
790
+ /\n\ncommand (?:timed out|was aborted|failed)/i.test(output);
791
+ const lineCount = output
792
+ .split("\n")
793
+ .filter((line: string) => line.trim()).length;
794
+ let text = theme.fg(
795
+ failed ? "error" : "success",
796
+ failed
797
+ ? exitCode !== null
798
+ ? zh.tools.powershell.exit(exitCode)
799
+ : zh.tools.powershell.failed
800
+ : zh.tools.powershell.done,
801
+ );
802
+ text += theme.fg("dim", ` (${zh.tools.powershell.lines(lineCount)})`);
803
+ if (details?.truncation?.truncated) {
804
+ text += theme.fg("warning", ` ${zh.tools.powershell.truncated}`);
805
+ }
806
+ if (expanded) {
807
+ for (const line of output.split("\n")) {
808
+ text += `\n${theme.fg("dim", line)}`;
809
+ }
810
+ }
811
+ return new Text(text, 0, 0);
812
+ },
813
+ });
814
+ }
815
+
816
+ // 同名注册可能改变 active 集合;恢复注册前的工具启用状态。
817
+ pi.setActiveTools(activeTools);
818
+ }
819
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "pi-zh-cn",
3
+ "version": "0.1.0",
4
+ "description": "pi 编码代理的简体中文界面汉化插件",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "i18n",
9
+ "chinese"
10
+ ],
11
+ "files": [
12
+ "extensions"
13
+ ],
14
+ "pi": {
15
+ "extensions": [
16
+ "./extensions/index.ts"
17
+ ]
18
+ },
19
+ "peerDependencies": {
20
+ "@earendil-works/pi-ai": "*",
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-tui": "*"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^26.3.0",
26
+ "typescript": "^7.0.2"
27
+ }
28
+ }