pi-zh-cn 0.1.1 → 0.2.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 CHANGED
@@ -1,58 +1,50 @@
1
1
  # pi-zh-cn
2
2
 
3
- pi([@earendil-works/pi-coding-agent](https://github.com/earendil-works/pi))简体中文 TUI 界面汉化插件。
4
- 纯扩展 API 实现,不 patch 源码、不侵入安装;正常情况下可随 pi 升级保持兼容。
3
+ 把 [pi](https://github.com/earendil-works/pi) 编码代理的界面变成简体中文。装上重启即可,不想要了随时卸载。
5
4
 
6
- ## 汉化范围
5
+ 原理上它只是一个普通扩展:调用官方扩展 API 替换文案,不打补丁、不改安装目录里的任何文件,所以 pi 正常升级后一般也能直接用。
7
6
 
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」+ 中文执行状态 |
7
+ ## 会变成中文的部分
22
8
 
23
- **明确不改**:工具 description(发给模型的内容保持英文,避免影响模型行为)、
24
- 内置菜单及其他核心 chrome(扩展 API 覆盖不到,见下方“已知边界”)。
9
+ - 启动画面:标题、快捷键提示、引导语(快捷键显示跟随你自己的键位配置)
10
+ - 底部状态栏:当前目录 / 分支 / 会话名、token 与缓存用量、费用、上下文占比、模型和思考等级
11
+ - 加载提示:「正在处理…」;thinking 折叠块的标签
12
+ - 八个内置工具的执行结果:读取、执行(bash)、编辑、写入、搜索内容、查找文件、列出目录、PowerShell——状态行都是中文,比如「完成」「退出码 N」「输出过长已截断」
13
+ - 项目信任弹窗:信任/不信任选择,含「信任父文件夹」变体
25
14
 
26
- 运行时可用 `/footer` 在中文 footer 和 built-in footer 之间切换,方便核对统计数据。
15
+ 发给模型的内容不变。工具的 description 保持英文原文,避免影响模型行为。
27
16
 
28
- 插件只替换 pi 已提供工具的渲染,不改变原有 active 工具集合;`grep`、`find`、`ls` 或 `powershell` 未启用时,不会因安装本插件而自动启用。
17
+ ## 保持原样的部分
29
18
 
30
- ## 安装
19
+ - 内置菜单和其他硬编码在 pi 源码里的英文(扩展 API 够不到,除非 fork 上游重编译,不值得)
20
+ - 工具的启用范围:`grep`、`find`、`ls` 或 `powershell` 没开就不会被这个插件偷偷打开
31
21
 
32
- ```bash
33
- # 本地路径安装(路径引用,不复制文件)
34
- pi install /path/to/pi-zh
22
+ ## 使用
35
23
 
36
- # 或发布 npm 后
24
+ ```bash
25
+ # 从 npm 安装
37
26
  pi install npm:pi-zh-cn
38
27
 
39
- # 查看已安装包(remove 时使用这里显示的 source)
28
+ # 查看/卸载用这里显示的 source 名
40
29
  pi list
41
30
  ```
42
31
 
43
- ## 已知边界
32
+ 觉得中文状态栏不如原生好读,可以临时切回英文:
44
33
 
45
- - pi 无内置 i18n,内置菜单、`esc to …` 提示行等硬编码英文文案无法通过扩展 API 修改。
46
- 若需彻底汉化,需要 fork 上游或 patch `dist/bundle`(维护成本高,本插件不做)。
34
+ ```text
35
+ /footer
36
+ ```
47
37
 
48
38
  ## 开发
49
39
 
50
40
  ```bash
51
- npm install # 安装 devDependencies(含 peer 自动安装)
41
+ npm install
52
42
  ./node_modules/.bin/tsc -p . # 类型检查
53
43
 
54
44
  # 本地试跑
55
45
  pi --no-session -e ./extensions/index.ts -p "hi"
56
46
  ```
57
47
 
58
- 文案集中在 [`extensions/i18n.ts`](extensions/i18n.ts),改词只需动这一个文件。
48
+ 所有文案都集中在 [`extensions/zh.ts`](extensions/zh.ts),想改词只动这一个文件就够了。
49
+
50
+ 改动记录见 [CHANGELOG.md](CHANGELOG.md)。pi 大版本升级后如果出现错位或残留英文,欢迎提 issue。
@@ -0,0 +1,309 @@
1
+ /**
2
+ * 中文 footer:逐行复刻内置信息(pwd 行 / 统计行 / 扩展状态行),仅描述文案用中文。
3
+ */
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join, relative } from "node:path";
7
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
8
+ import {
9
+ CONFIG_DIR_NAME,
10
+ getAgentDir,
11
+ type ExtensionContext,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
14
+ import { zh } from "./zh.ts";
15
+
16
+ /**
17
+ * 安装中文 footer(仅 TUI 模式,其他模式返回 undefined)。
18
+ *
19
+ * footer 逐行复刻内置信息结构:
20
+ * 1. pwd 行 —— 当前目录(~ 缩写)+ git 分支 + 会话名;
21
+ * 2. 统计行 —— 左侧为输入/输出 token、缓存读写与命中率、费用、上下文
22
+ * 占比(按阈值变色),右侧为 provider • 模型 • 思考等级(放不下时省略);
23
+ * 3. 扩展状态行 —— 各扩展 `setStatus` 的文本。
24
+ * 仅描述文案用中文,数据格式与 built-in 一致。
25
+ *
26
+ * @param ctx 当前会话上下文
27
+ * @returns 切换函数:第一次调用恢复内置 footer,再调用切回中文 footer;
28
+ * 非 TUI 模式下为 undefined
29
+ */
30
+ export function setupChineseFooter(ctx: ExtensionContext): (() => void) | undefined {
31
+ if (ctx.mode !== "tui") return undefined;
32
+
33
+ let usingZhFooter = true;
34
+
35
+ const applyZhFooter = () => {
36
+ usingZhFooter = true;
37
+ // 自动压缩标记:全局设置为基准,受信任项目的本地设置优先。
38
+ // 一秒缓存避免流式渲染时频繁读文件,同时能跟随 /settings 的运行时修改。
39
+ const settingsPaths = [join(getAgentDir(), "settings.json")];
40
+ if (ctx.isProjectTrusted())
41
+ settingsPaths.push(join(ctx.cwd, CONFIG_DIR_NAME, "settings.json"));
42
+ let autoCompactEnabled = true;
43
+ let autoCompactCheckedAt = 0;
44
+ /**
45
+ * 返回自动压缩标记文案。设置读取带 1 秒缓存:避免流式渲染时每次
46
+ * 重绘都读文件,同时仍能跟随 /settings 的运行时修改。全局设置为基准,
47
+ * 受信任项目的本地设置优先覆盖。
48
+ */
49
+ const getAutoCompactSuffix = () => {
50
+ const now = Date.now();
51
+ if (now - autoCompactCheckedAt >= 1000) {
52
+ autoCompactEnabled = true;
53
+ for (const settingsPath of settingsPaths) {
54
+ try {
55
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
56
+ if (typeof settings?.compaction?.enabled === "boolean") {
57
+ autoCompactEnabled = settings.compaction.enabled;
58
+ }
59
+ } catch {
60
+ // 设置文件不存在或内容无效时忽略该层。
61
+ }
62
+ }
63
+ autoCompactCheckedAt = now;
64
+ }
65
+ return autoCompactEnabled ? zh.footer.autoCompactSuffix : "";
66
+ };
67
+
68
+ let unsubscribeBranch: (() => void) | undefined;
69
+
70
+ // footer 渲染器:onBranchChange 触发重绘;dispose 时取消订阅。
71
+ ctx.ui.setFooter((tui, theme, footerData) => {
72
+ unsubscribeBranch?.();
73
+ unsubscribeBranch = footerData.onBranchChange(() =>
74
+ tui.requestRender(),
75
+ );
76
+
77
+ const home = homedir();
78
+ return {
79
+ dispose() {
80
+ unsubscribeBranch?.();
81
+ unsubscribeBranch = undefined;
82
+ },
83
+ invalidate() {},
84
+ /** 汇总会话 usage 统计并渲染三行中文 footer,见模块级说明。 */
85
+ render(width: number): string[] {
86
+ let input = 0,
87
+ output = 0,
88
+ cacheRead = 0,
89
+ cacheWrite = 0,
90
+ cost = 0;
91
+ const addUsage = (t: {
92
+ input: number;
93
+ output: number;
94
+ cacheRead: number;
95
+ cacheWrite: number;
96
+ cost: { total: number };
97
+ }) => {
98
+ input += t.input;
99
+ output += t.output;
100
+ cacheRead += t.cacheRead;
101
+ cacheWrite += t.cacheWrite;
102
+ cost += t.cost.total;
103
+ };
104
+ let latestRate: number | undefined;
105
+
106
+ for (const e of ctx.sessionManager.getEntries()) {
107
+ if (e.type === "message" && e.message.role === "assistant") {
108
+ const u = (e.message as AssistantMessage).usage;
109
+ addUsage(u);
110
+ const promptTokens = u.input + u.cacheRead + u.cacheWrite;
111
+ latestRate =
112
+ promptTokens > 0
113
+ ? (u.cacheRead / promptTokens) * 100
114
+ : undefined;
115
+ } else if (
116
+ e.type === "message" &&
117
+ e.message.role === "toolResult" &&
118
+ e.message.usage
119
+ ) {
120
+ addUsage(e.message.usage as any);
121
+ } else if (
122
+ (e.type === "branch_summary" || e.type === "compaction") &&
123
+ (e as any).usage
124
+ ) {
125
+ addUsage((e as any).usage);
126
+ }
127
+ }
128
+
129
+ const fmt = (n: number) =>
130
+ n < 1e3
131
+ ? `${n}`
132
+ : n < 1e4
133
+ ? `${(n / 1e3).toFixed(1)}k`
134
+ : n < 1e6
135
+ ? `${Math.round(n / 1e3)}k`
136
+ : n < 1e7
137
+ ? `${(n / 1e6).toFixed(1)}M`
138
+ : `${Math.round(n / 1e6)}M`;
139
+
140
+ // 第 1 行:当前目录:~路径(分支)• 会话名
141
+ let pwd = ctx.sessionManager.getCwd();
142
+ const homeRelative = relative(home, pwd);
143
+ if (homeRelative === "") pwd = "~";
144
+ else if (
145
+ homeRelative &&
146
+ !homeRelative.startsWith("..") &&
147
+ !homeRelative.startsWith("/")
148
+ )
149
+ pwd = `~/${homeRelative}`;
150
+ const branch = footerData.getGitBranch();
151
+ if (branch) pwd += ` ${zh.footer.branchPrefix}(${branch})`;
152
+ const sessionName = ctx.sessionManager.getSessionName();
153
+ if (sessionName) pwd += ` • ${sessionName}`;
154
+ pwd = `${zh.footer.cwdPrefix}${pwd}`;
155
+
156
+ // 第 2 行左:严格按内置顺序:输入、输出、缓存、费用、上下文占比。
157
+ const clauses: string[] = [];
158
+ if (input > 0 || output > 0) {
159
+ const io: string[] = [];
160
+ if (input > 0) io.push(`${zh.footer.inputLabel}↑${fmt(input)}`);
161
+ if (output > 0)
162
+ io.push(`${zh.footer.outputLabel}↓${fmt(output)}`);
163
+ clauses.push(io.join("/"));
164
+ }
165
+
166
+ const cacheParts: string[] = [];
167
+ if (cacheRead > 0)
168
+ cacheParts.push(`${zh.footer.cacheReadLabel}${fmt(cacheRead)}`);
169
+ if (cacheWrite > 0)
170
+ cacheParts.push(
171
+ `${zh.footer.cacheWriteLabel}${fmt(cacheWrite)}`,
172
+ );
173
+ if (
174
+ (cacheRead > 0 || cacheWrite > 0) &&
175
+ latestRate !== undefined
176
+ ) {
177
+ cacheParts.push(
178
+ `${zh.footer.cacheHitLabel}${latestRate.toFixed(1)}%`,
179
+ );
180
+ }
181
+ if (cacheParts.length > 0) clauses.push(cacheParts.join(" "));
182
+
183
+ const provider = ctx.model
184
+ ? ctx.modelRegistry.getProvider(ctx.model.provider)
185
+ : undefined;
186
+ const usingSubscription =
187
+ ctx.model?.provider === "kimi-coding" ||
188
+ (!!ctx.model &&
189
+ ctx.modelRegistry.isUsingOAuth(ctx.model) &&
190
+ provider?.auth.oauth?.isSubscription === true);
191
+ if (cost > 0 || usingSubscription) {
192
+ clauses.push(
193
+ `${zh.footer.costLabel}$${cost.toFixed(3)}${usingSubscription ? zh.footer.subSuffix : ""}`,
194
+ );
195
+ }
196
+
197
+ // built-in 顺序的最后一项:上下文占比 / 上下文窗口(自动压缩)。
198
+ const cu = ctx.getContextUsage();
199
+ const windowSize =
200
+ cu?.contextWindow ?? ctx.model?.contextWindow ?? 0;
201
+ const pctValue = cu?.percent ?? 0;
202
+ // 与 built-in 一致:明确为 null 时显示未知;尚无 usage 时按 0.0% 显示。
203
+ const pctText =
204
+ cu?.percent === null ? "?" : `${pctValue.toFixed(1)}%`;
205
+ const contextClause = `${zh.footer.ratioLabel}${pctText}/${fmt(windowSize)}${getAutoCompactSuffix()}`;
206
+ const contextIndex = clauses.length;
207
+ clauses.push(contextClause);
208
+
209
+ // 占比按阈值变色(>90% 红、>70% 黄);实验标记沿用 built-in 样式。
210
+ const statsLeft = clauses
211
+ .map((clause, index) => {
212
+ if (index === contextIndex) {
213
+ return pctValue > 90
214
+ ? theme.fg("error", clause)
215
+ : pctValue > 70
216
+ ? theme.fg("warning", clause)
217
+ : theme.fg("dim", clause);
218
+ }
219
+ return theme.fg("dim", clause);
220
+ })
221
+ .join(" ");
222
+
223
+ // 第 2 行右:provider • 模型 • 思考等级(等级映射为中文)
224
+ let rightSideWithoutProvider = ctx.model?.id ?? zh.footer.noModel;
225
+ if (ctx.model?.reasoning) {
226
+ const tl = ctx.thinkingLevel || "off";
227
+ const tlLabel =
228
+ tl === "off"
229
+ ? zh.footer.thinkingOff
230
+ : ((zh.footer.thinkingLevels as Record<string, string>)[
231
+ tl
232
+ ] ?? tl);
233
+ rightSideWithoutProvider = `${rightSideWithoutProvider} • ${tlLabel}`;
234
+ }
235
+ let rightSide = rightSideWithoutProvider;
236
+ if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
237
+ rightSide = `(${ctx.model.provider}) ${rightSideWithoutProvider}`;
238
+ if (
239
+ visibleWidth(statsLeft) + 2 + visibleWidth(rightSide) >
240
+ width
241
+ ) {
242
+ rightSide = rightSideWithoutProvider;
243
+ }
244
+ }
245
+
246
+ let statsDisplay = statsLeft;
247
+ let statsWidth = visibleWidth(statsDisplay);
248
+ if (statsWidth > width) {
249
+ statsDisplay = truncateToWidth(statsDisplay, width, "...");
250
+ statsWidth = visibleWidth(statsDisplay);
251
+ }
252
+ const availableForRight = width - statsWidth - 2;
253
+ let statsLine = statsDisplay;
254
+ if (availableForRight > 0) {
255
+ const truncatedRight = truncateToWidth(
256
+ rightSide,
257
+ availableForRight,
258
+ "",
259
+ );
260
+ const padding = " ".repeat(
261
+ Math.max(
262
+ 0,
263
+ width - statsWidth - visibleWidth(truncatedRight),
264
+ ),
265
+ );
266
+ statsLine = `${statsDisplay}${padding}${theme.fg("dim", truncatedRight)}`;
267
+ }
268
+
269
+ const lines = [
270
+ truncateToWidth(
271
+ theme.fg("dim", pwd),
272
+ width,
273
+ theme.fg("dim", "..."),
274
+ ),
275
+ ];
276
+ lines.push(statsLine);
277
+
278
+ // 第 3 行:各扩展的 setStatus 状态
279
+ const statuses = footerData.getExtensionStatuses();
280
+ if (statuses.size > 0) {
281
+ const statusLine = Array.from(statuses.entries())
282
+ .sort(([a], [b]) => a.localeCompare(b))
283
+ .map(([, text]) =>
284
+ text
285
+ .replace(/[\r\n\t]/g, " ")
286
+ .replace(/ +/g, " ")
287
+ .trim(),
288
+ )
289
+ .join(" ");
290
+ lines.push(
291
+ truncateToWidth(statusLine, width, theme.fg("dim", "...")),
292
+ );
293
+ }
294
+ return lines;
295
+ },
296
+ };
297
+ });
298
+ };
299
+
300
+ applyZhFooter();
301
+ return () => {
302
+ if (usingZhFooter) {
303
+ usingZhFooter = false;
304
+ ctx.ui.setFooter(undefined);
305
+ } else {
306
+ applyZhFooter();
307
+ }
308
+ };
309
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 中文 header:复刻内置结构(标题 + 折叠提示行 + 引导语),
3
+ * 快捷键跟随用户键位配置。
4
+ */
5
+ import {
6
+ keyHint,
7
+ keyText,
8
+ rawKeyHint,
9
+ VERSION,
10
+ type ExtensionContext,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { zh } from "./zh.ts";
13
+
14
+ /**
15
+ * 设置中文启动 header(仅 TUI 模式,其他模式无效果)。
16
+ *
17
+ * 复刻内置结构:logo + 折叠提示行(快捷键跟随用户键位配置)+
18
+ * 展开帮助语 + 引导文案。
19
+ *
20
+ * @param ctx 当前会话上下文
21
+ */
22
+ export function setChineseHeader(ctx: ExtensionContext) {
23
+ if (ctx.mode !== "tui") return;
24
+ ctx.ui.setHeader((_tui, theme) => ({
25
+ render(_width: number): string[] {
26
+ const logo =
27
+ theme.bold(theme.fg("accent", "pi")) +
28
+ theme.fg("dim", ` v${VERSION}`);
29
+ const compact = [
30
+ keyHint("app.interrupt", zh.header.interrupt),
31
+ rawKeyHint(
32
+ `${keyText("app.clear")}/${keyText("app.exit")}`,
33
+ zh.header.clearExit,
34
+ ),
35
+ rawKeyHint("/", zh.header.commands),
36
+ rawKeyHint("!", "bash"),
37
+ keyHint("app.tools.expand", zh.header.more),
38
+ ].join(theme.fg("muted", " · "));
39
+ return [
40
+ logo,
41
+ compact,
42
+ theme.fg(
43
+ "dim",
44
+ `${zh.header.expandHelp} (${keyText("app.tools.expand")})`,
45
+ ),
46
+ "",
47
+ theme.fg("dim", zh.header.onboarding),
48
+ ];
49
+ },
50
+ invalidate() {},
51
+ }));
52
+ }