pi-zh-cn 0.1.0 → 0.2.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 CHANGED
@@ -1,58 +1,49 @@
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」「输出过长已截断」
25
13
 
26
- 运行时可用 `/footer` 在中文 footer 和 built-in footer 之间切换,方便核对统计数据。
14
+ 发给模型的内容不变。工具的 description 保持英文原文,避免影响模型行为。
27
15
 
28
- 插件只替换 pi 已提供工具的渲染,不改变原有 active 工具集合;`grep`、`find`、`ls` 或 `powershell` 未启用时,不会因安装本插件而自动启用。
16
+ ## 保持原样的部分
29
17
 
30
- ## 安装
18
+ - 内置菜单和其他硬编码在 pi 源码里的英文(扩展 API 够不到,除非 fork 上游重编译,不值得)
19
+ - 工具的启用范围:`grep`、`find`、`ls` 或 `powershell` 没开就不会被这个插件偷偷打开
31
20
 
32
- ```bash
33
- # 本地路径安装(路径引用,不复制文件)
34
- pi install /path/to/pi-zh
21
+ ## 使用
35
22
 
36
- # 或发布 npm 后
23
+ ```bash
24
+ # 从 npm 安装
37
25
  pi install npm:pi-zh-cn
38
26
 
39
- # 查看已安装包(remove 时使用这里显示的 source)
27
+ # 查看/卸载用这里显示的 source 名
40
28
  pi list
41
29
  ```
42
30
 
43
- ## 已知边界
31
+ 觉得中文状态栏不如原生好读,可以临时切回英文:
44
32
 
45
- - pi 无内置 i18n,内置菜单、`esc to …` 提示行等硬编码英文文案无法通过扩展 API 修改。
46
- 若需彻底汉化,需要 fork 上游或 patch `dist/bundle`(维护成本高,本插件不做)。
33
+ ```text
34
+ /footer
35
+ ```
47
36
 
48
37
  ## 开发
49
38
 
50
39
  ```bash
51
- npm install # 安装 devDependencies(含 peer 自动安装)
52
- npm exec tsc -p tsconfig.json # 类型检查
40
+ npm install
41
+ ./node_modules/.bin/tsc -p . # 类型检查
53
42
 
54
43
  # 本地试跑
55
44
  pi --no-session -e ./extensions/index.ts -p "hi"
56
45
  ```
57
46
 
58
- 文案集中在 [`extensions/i18n.ts`](extensions/i18n.ts),改词只需动这一个文件。
47
+ 所有文案都集中在 [`extensions/zh.ts`](extensions/zh.ts),想改词只动这一个文件就够了。
48
+
49
+ 改动记录见 [CHANGELOG.md](CHANGELOG.md)。pi 大版本升级后如果出现错位或残留英文,欢迎提 issue。
@@ -6,6 +6,8 @@
6
6
  * - thinking 折叠块标签(setHiddenThinkingLabel)
7
7
  * - 启动 header(setHeader)
8
8
  * - 底部状态栏(setFooter)
9
+ * - 项目信任弹窗(project_trust handler + 中文 select)
10
+ * - 内置斜杠命令描述(addAutocompleteProvider 包一层翻译)
9
11
  * - 内置 read/bash/edit/write 工具的渲染文案(同名重注册,执行委托原实现)
10
12
  *
11
13
  * 不改动发给模型的内容(工具 description 保持英文)。
@@ -15,10 +17,6 @@ import type {
15
17
  BashToolDetails,
16
18
  EditToolDetails,
17
19
  ExtensionAPI,
18
- FindToolDetails,
19
- GrepToolDetails,
20
- LsToolDetails,
21
- PowerShellToolDetails,
22
20
  ReadToolDetails,
23
21
  } from "@earendil-works/pi-coding-agent";
24
22
  import {
@@ -38,11 +36,11 @@ import {
38
36
  VERSION,
39
37
  } from "@earendil-works/pi-coding-agent";
40
38
  import { homedir } from "node:os";
41
- import { readFileSync } from "node:fs";
42
- import { join, relative } from "node:path";
39
+ import { readFileSync, realpathSync } from "node:fs";
40
+ import { dirname, join, relative } from "node:path";
43
41
  import type { AssistantMessage } from "@earendil-works/pi-ai";
44
42
  import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
- import { zh } from "./i18n.ts";
43
+ import { zh } from "./zh.ts";
46
44
 
47
45
  export default function (pi: ExtensionAPI) {
48
46
  let toggleFooter: (() => void) | undefined;
@@ -55,13 +53,84 @@ export default function (pi: ExtensionAPI) {
55
53
  },
56
54
  });
57
55
 
56
+ // ---------- 项目信任弹窗(中文选择器代替内置英文界面) ----------
57
+ // 复刻 ~/「agent dir」/trust.json 的祖先目录查找逻辑;已有决定的目录不再弹窗。
58
+ // 与内置的差异:「信任父文件夹」选项无法通过扩展 API 写入父目录决定,故省略。
59
+ const savedTrustDecision = (dir: string): boolean | null => {
60
+ let current = dir;
61
+ try {
62
+ current = realpathSync(current); // 与 core 的 normalizeCwd 一致
63
+ } catch {
64
+ // 目录不存在时保持原样
65
+ }
66
+ try {
67
+ const store = JSON.parse(readFileSync(join(getAgentDir(), "trust.json"), "utf8"));
68
+ for (;;) {
69
+ const decision = store[current];
70
+ if (typeof decision === "boolean") return decision;
71
+ const parent = dirname(current);
72
+ if (parent === current) return null;
73
+ current = parent;
74
+ }
75
+ } catch {
76
+ return null; // 无 trust.json 或内容无效 → 视作没有已存决定
77
+ }
78
+ };
79
+
80
+ pi.on("project_trust", async (event, ctx) => {
81
+ // 无 UI(rpc/print 等模式)或已有保存的决定时返回 undecided,让 core 走默认流程。
82
+ if (!ctx.hasUI || savedTrustDecision(event.cwd) !== null) return { trusted: "undecided" };
83
+
84
+ const options = [
85
+ zh.trust.options.trustAndRemember,
86
+ zh.trust.options.trustSessionOnly,
87
+ zh.trust.options.distrustAndRemember,
88
+ zh.trust.options.distrustSessionOnly,
89
+ ];
90
+ const selected = await ctx.ui.select(zh.trust.title(event.cwd), options);
91
+ switch (selected ?? zh.trust.options.distrustSessionOnly) {
92
+ case zh.trust.options.trustAndRemember:
93
+ return { trusted: "yes", remember: true };
94
+ case zh.trust.options.trustSessionOnly:
95
+ return { trusted: "yes" };
96
+ case zh.trust.options.distrustAndRemember:
97
+ return { trusted: "no", remember: true };
98
+ default:
99
+ return { trusted: "no" }; // 取消 = 本次不信任(与内置行为一致)
100
+ }
101
+ });
102
+
58
103
  // ---------- UI 文案 + 中文 header/footer(session 启动时应用) ----------
104
+ let autocompleteTranslated = false;
59
105
  pi.on("session_start", (_event, ctx) => {
60
106
  if (!ctx.hasUI) return;
61
107
 
62
108
  ctx.ui.setWorkingMessage(zh.workingMessage);
63
109
  ctx.ui.setHiddenThinkingLabel(zh.hiddenThinkingLabel);
64
110
 
111
+ // 中文化内置斜杠命令描述:包一层自动补全 provider,只替换命令项的
112
+ // description(文件补全等其他建议原样透传)。替换是幂等的,跨 session 只包一次。
113
+ if (ctx.mode === "tui" && !autocompleteTranslated) {
114
+ autocompleteTranslated = true;
115
+ ctx.ui.addAutocompleteProvider((current) => ({
116
+ ...(current.triggerCharacters ? { triggerCharacters: current.triggerCharacters } : {}),
117
+ getSuggestions: async (lines, cursorLine, cursorCol, options) => {
118
+ const suggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
119
+ if (!suggestions) return null;
120
+ return {
121
+ ...suggestions,
122
+ items: suggestions.items.map((item) => {
123
+ const key = item.value.replace(/^\//, "") as keyof typeof zh.builtinCommands;
124
+ const zhDesc = zh.builtinCommands[key];
125
+ return zhDesc ? { ...item, description: zhDesc } : item;
126
+ }),
127
+ };
128
+ },
129
+ applyCompletion: (lines, cursorLine, cursorCol, item, prefix) =>
130
+ current.applyCompletion(lines, cursorLine, cursorCol, item, prefix),
131
+ }));
132
+ }
133
+
65
134
  // 中文 header:复刻内置结构(标题 + 折叠提示行 + 引导语),快捷键跟随用户键位配置。
66
135
  if (ctx.mode === "tui") {
67
136
  ctx.ui.setHeader((_tui, theme) => ({
@@ -77,8 +146,18 @@ export default function (pi: ExtensionAPI) {
77
146
  ),
78
147
  rawKeyHint("/", zh.header.commands),
79
148
  rawKeyHint("!", "bash"),
149
+ keyHint("app.tools.expand", zh.header.more),
80
150
  ].join(theme.fg("muted", " · "));
81
- return [logo, compact, "", theme.fg("dim", zh.header.onboarding)];
151
+ return [
152
+ logo,
153
+ compact,
154
+ theme.fg(
155
+ "dim",
156
+ `${zh.header.expandHelp} (${keyText("app.tools.expand")})`,
157
+ ),
158
+ "",
159
+ theme.fg("dim", zh.header.onboarding),
160
+ ];
82
161
  },
83
162
  invalidate() {},
84
163
  }));
@@ -254,9 +333,6 @@ export default function (pi: ExtensionAPI) {
254
333
  const contextClause = `${zh.footer.ratioLabel}${pctText}/${fmt(windowSize)}${getAutoCompactSuffix()}`;
255
334
  const contextIndex = clauses.length;
256
335
  clauses.push(contextClause);
257
- const xpIndex =
258
- process.env.PI_EXPERIMENTAL === "1" ? clauses.length : -1;
259
- if (xpIndex >= 0) clauses.push("xp");
260
336
 
261
337
  // 占比按阈值变色(>90% 红、>70% 黄);实验标记沿用 built-in 样式。
262
338
  const statsLeft = clauses
@@ -268,9 +344,6 @@ export default function (pi: ExtensionAPI) {
268
344
  ? theme.fg("warning", clause)
269
345
  : theme.fg("dim", clause);
270
346
  }
271
- if (index === xpIndex) {
272
- return `${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`;
273
- }
274
347
  return theme.fg("dim", clause);
275
348
  })
276
349
  .join(" ");
@@ -366,6 +439,137 @@ export default function (pi: ExtensionAPI) {
366
439
  registerLocalizedTools(ctx.cwd);
367
440
  });
368
441
 
442
+ // ---------- 工具渲染通用小工具 ----------
443
+ const textOf = (result: any): string =>
444
+ result.content?.[0]?.type === "text" ? result.content[0].text : "";
445
+
446
+ const countNonEmpty = (s: string): number =>
447
+ s.split("\n").filter((l) => l.trim()).length;
448
+
449
+ const notePartial = (msg: string, theme: any): Text =>
450
+ new Text(theme.fg("warning", msg), 0, 0);
451
+
452
+ // 错误结果:内容以 "Error" 开头时只展示首行(edit/write 共用)。
453
+ const errorFirstLine = (result: any, theme: any): Text | undefined => {
454
+ const out = textOf(result);
455
+ return out.startsWith("Error")
456
+ ? new Text(theme.fg("error", out.split("\n")[0]), 0, 0)
457
+ : undefined;
458
+ };
459
+
460
+ // 展开态:在摘要后追加逐行弱化的原始输出。
461
+ const withExpanded = (raw: string, summary: string, theme: any): string =>
462
+ raw
463
+ ? `${summary}\n${raw
464
+ .split("\n")
465
+ .map((l) => theme.fg("dim", l))
466
+ .join("\n")}`
467
+ : summary;
468
+
469
+ // 失败判定(bash/powershell 共用):解析退出码;powershell 通过 extraRe
470
+ // 追加 Windows 平台提示的识别。
471
+ function failureState(output: string, extraRe: RegExp[] = []) {
472
+ const m = output.match(
473
+ /(?:exit code:\s*|command exited with code\s+)(\d+)/i,
474
+ );
475
+ const exitCode = m ? parseInt(m[1], 10) : null;
476
+ return {
477
+ exitCode,
478
+ failed:
479
+ exitCode !== null ||
480
+ /^error\b/i.test(output) ||
481
+ /\n\ncommand (?:timed out|was aborted|failed)/i.test(output) ||
482
+ extraRe.some((re) => re.test(output)),
483
+ };
484
+ }
485
+
486
+ // shell 类工具(bash/powershell 文案同构)的渲染对。
487
+ type ShellCopy =
488
+ | typeof zh.tools.bash
489
+ | typeof zh.tools.powershell;
490
+ const shellRenders = (w: ShellCopy, extraRe: RegExp[] = []) => ({
491
+ renderCall(args: any, theme: any): Text {
492
+ let text = theme.fg("toolTitle", theme.bold(w.title));
493
+ const cmd: string =
494
+ args.command.length > 80
495
+ ? `${args.command.slice(0, 77)}...`
496
+ : args.command;
497
+ text += theme.fg("accent", cmd);
498
+ if (args.timeout) text += theme.fg("dim", w.timeout(args.timeout));
499
+ return new Text(text, 0, 0);
500
+ },
501
+
502
+ renderResult(
503
+ result: any,
504
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
505
+ theme: any,
506
+ ): Text {
507
+ if (isPartial) return notePartial(w.running, theme);
508
+ const output = textOf(result);
509
+ const { exitCode, failed } = failureState(output, extraRe);
510
+ let text = failed
511
+ ? theme.fg(
512
+ "error",
513
+ exitCode !== null ? w.exit(exitCode) : w.failed,
514
+ )
515
+ : theme.fg("success", w.done);
516
+ text += theme.fg("dim", ` (${w.lines(countNonEmpty(output))})`);
517
+ if (
518
+ (result.details as BashToolDetails | undefined)?.truncation?.truncated
519
+ ) {
520
+ text += theme.fg("warning", ` ${w.truncated}`);
521
+ }
522
+ return new Text(withExpanded(expanded ? output : "", text, theme), 0, 0);
523
+ },
524
+ });
525
+
526
+ // 计数类工具(grep/find/ls 文案同构)的结果渲染:无匹配哨兵、条数统计、
527
+ // 上限提示与截断警告四处逻辑一致,仅文案不同。
528
+ function countResultRender(
529
+ labels: {
530
+ searching: string;
531
+ noMatch: string;
532
+ found: (n: number) => string;
533
+ limit?: (n: number) => string;
534
+ truncated: string;
535
+ },
536
+ sentinel: string,
537
+ limitField: string,
538
+ ) {
539
+ return (
540
+ result: any,
541
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
542
+ theme: any,
543
+ ): Text => {
544
+ if (isPartial) return notePartial(labels.searching, theme);
545
+ const details = result.details ?? {};
546
+ const output = textOf(result);
547
+ const isEmpty = output === sentinel;
548
+ let text = theme.fg(
549
+ isEmpty ? "muted" : "success",
550
+ isEmpty ? labels.noMatch : labels.found(countNonEmpty(output)),
551
+ );
552
+ if (labels.limit && typeof details[limitField] === "number") {
553
+ text += theme.fg("warning", labels.limit(details[limitField]));
554
+ }
555
+ // 与旧实现的行为差异:grep 原本同时检查 linesTruncated,find/ls 只查
556
+ // truncation.truncated;这里统一为两者都检查,Find/LsToolDetails 目前无
557
+ // linesTruncated 字段,运行时行为等价。若上游新增该字段需复核。
558
+ if (details.linesTruncated || details.truncation?.truncated) {
559
+ text += theme.fg("warning", labels.truncated);
560
+ }
561
+ return new Text(
562
+ withExpanded(
563
+ expanded ? (isEmpty ? labels.noMatch : output) : "",
564
+ text,
565
+ theme,
566
+ ),
567
+ 0,
568
+ 0,
569
+ );
570
+ };
571
+ }
572
+
369
573
  function registerLocalizedTools(cwd: string) {
370
574
  const activeTools = pi.getActiveTools();
371
575
  const builtinTools = new Set(
@@ -377,12 +581,11 @@ export default function (pi: ExtensionAPI) {
377
581
 
378
582
  // ---------- 内置工具汉化渲染 ----------
379
583
  // read
380
- const originalRead = createReadTool(cwd);
381
584
  pi.registerTool({
382
- ...originalRead,
585
+ ...createReadTool(cwd),
383
586
  label: zh.tools.read.label,
384
587
 
385
- renderCall(args: any, theme: any, _context: any) {
588
+ renderCall(args: any, theme: any) {
386
589
  let text = theme.fg("toolTitle", theme.bold(`${zh.tools.read.title} `));
387
590
  text += theme.fg("accent", args.path);
388
591
  if (args.offset || args.limit) {
@@ -398,14 +601,10 @@ export default function (pi: ExtensionAPI) {
398
601
  result: any,
399
602
  { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
400
603
  theme: any,
401
- _context: any,
402
604
  ) {
403
- if (isPartial)
404
- return new Text(theme.fg("warning", zh.tools.read.reading), 0, 0);
605
+ if (isPartial) return notePartial(zh.tools.read.reading, theme);
405
606
 
406
- const details = result.details as ReadToolDetails | undefined;
407
607
  const content = result.content[0];
408
-
409
608
  if (content?.type === "image") {
410
609
  return new Text(theme.fg("success", zh.tools.read.imageLoaded), 0, 0);
411
610
  }
@@ -413,103 +612,40 @@ export default function (pi: ExtensionAPI) {
413
612
  return new Text(theme.fg("error", zh.tools.read.noContent), 0, 0);
414
613
  }
415
614
 
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) {
615
+ let text = theme.fg(
616
+ "success",
617
+ zh.tools.read.lines(content.text.split("\n").length),
618
+ );
619
+ const truncation = (result.details as ReadToolDetails | undefined)
620
+ ?.truncation;
621
+ if (truncation?.truncated) {
420
622
  text += theme.fg(
421
623
  "warning",
422
- zh.tools.read.truncatedFrom(details.truncation.totalLines),
624
+ zh.tools.read.truncatedFrom(truncation.totalLines),
423
625
  );
424
626
  }
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);
627
+ return new Text(
628
+ withExpanded(expanded ? content.text : "", text, theme),
629
+ 0,
630
+ 0,
631
+ );
433
632
  },
434
633
  });
435
634
 
436
- // bash
437
- const originalBash = createBashTool(cwd);
635
+ // bash / powershell(渲染逻辑同构,仅文案与失败判定集合不同)
438
636
  pi.registerTool({
439
- ...originalBash,
637
+ ...createBashTool(cwd),
440
638
  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
- },
639
+ ...shellRenders(zh.tools.bash),
503
640
  });
504
641
 
505
642
  // edit
506
- const originalEdit = createEditTool(cwd);
507
643
  pi.registerTool({
508
- ...originalEdit,
644
+ ...createEditTool(cwd),
509
645
  label: zh.tools.edit.label,
510
646
  renderShell: "self",
511
647
 
512
- renderCall(args: any, theme: any, _context: any) {
648
+ renderCall(args: any, theme: any) {
513
649
  let text = theme.fg("toolTitle", theme.bold(zh.tools.edit.title));
514
650
  text += theme.fg("accent", args.path);
515
651
  return new Text(text, 0, 0);
@@ -519,25 +655,20 @@ export default function (pi: ExtensionAPI) {
519
655
  result: any,
520
656
  { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
521
657
  theme: any,
522
- _context: any,
523
658
  ) {
524
- if (isPartial)
525
- return new Text(theme.fg("warning", zh.tools.edit.editing), 0, 0);
659
+ if (isPartial) return notePartial(zh.tools.edit.editing, theme);
526
660
 
527
- const details = result.details as EditToolDetails | undefined;
528
- const content = result.content[0];
661
+ const err = errorFirstLine(result, theme);
662
+ if (err) return err;
529
663
 
530
- if (content?.type === "text" && content.text.startsWith("Error")) {
531
- return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0);
532
- }
664
+ const details = result.details as EditToolDetails | undefined;
533
665
  if (!details?.diff) {
534
666
  return new Text(theme.fg("success", zh.tools.edit.applied), 0, 0);
535
667
  }
536
668
 
537
- const diffLines: string[] = details.diff.split("\n");
538
669
  let additions = 0,
539
670
  removals = 0;
540
- for (const line of diffLines) {
671
+ for (const line of details.diff.split("\n")) {
541
672
  if (line.startsWith("+") && !line.startsWith("+++")) additions++;
542
673
  if (line.startsWith("-") && !line.startsWith("---")) removals++;
543
674
  }
@@ -546,23 +677,20 @@ export default function (pi: ExtensionAPI) {
546
677
  text += theme.fg("dim", " / ");
547
678
  text += theme.fg("error", zh.tools.edit.removals(removals));
548
679
 
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);
680
+ return new Text(
681
+ withExpanded(expanded ? details.diff : "", text, theme),
682
+ 0,
683
+ 0,
684
+ );
556
685
  },
557
686
  });
558
687
 
559
688
  // write
560
- const originalWrite = createWriteTool(cwd);
561
689
  pi.registerTool({
562
- ...originalWrite,
690
+ ...createWriteTool(cwd),
563
691
  label: zh.tools.write.label,
564
692
 
565
- renderCall(args: any, theme: any, _context: any) {
693
+ renderCall(args: any, theme: any) {
566
694
  let text = theme.fg("toolTitle", theme.bold(zh.tools.write.title));
567
695
  text += theme.fg("accent", args.path);
568
696
  const lineCount = String(args.content).split("\n").length;
@@ -570,31 +698,21 @@ export default function (pi: ExtensionAPI) {
570
698
  return new Text(text, 0, 0);
571
699
  },
572
700
 
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
-
701
+ renderResult(result: any, { isPartial }: { isPartial: boolean }, theme: any) {
702
+ if (isPartial) return notePartial(zh.tools.write.writing, theme);
703
+ const err = errorFirstLine(result, theme);
704
+ if (err) return err;
587
705
  return new Text(theme.fg("success", zh.tools.write.written), 0, 0);
588
706
  },
589
707
  });
590
708
 
709
+ // grep / find / ls:调用渲染各自略有差异,结果渲染走同一个计数模板。
591
710
  if (builtinTools.has("grep")) {
592
- const originalGrep = createGrepTool(cwd);
593
711
  pi.registerTool({
594
- ...originalGrep,
712
+ ...createGrepTool(cwd),
595
713
  label: zh.tools.grep.label,
596
714
 
597
- renderCall(args: any, theme: any, _context: any) {
715
+ renderCall(args: any, theme: any) {
598
716
  let text = theme.fg("toolTitle", theme.bold(zh.tools.grep.title));
599
717
  text += theme.fg("accent", args.pattern);
600
718
  if (args.path) text += theme.fg("dim", ` · ${args.path}`);
@@ -602,214 +720,76 @@ export default function (pi: ExtensionAPI) {
602
720
  return new Text(text, 0, 0);
603
721
  },
604
722
 
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
- },
723
+ renderResult: countResultRender(
724
+ {
725
+ searching: zh.tools.grep.searching,
726
+ noMatch: zh.tools.grep.noMatches,
727
+ found: zh.tools.grep.matches,
728
+ limit: zh.tools.grep.matchLimit,
729
+ truncated: zh.tools.grep.truncated,
730
+ },
731
+ "No matches found",
732
+ "matchLimitReached",
733
+ ),
640
734
  });
641
735
  }
642
736
 
643
737
  if (builtinTools.has("find")) {
644
- const originalFind = createFindTool(cwd);
645
738
  pi.registerTool({
646
- ...originalFind,
739
+ ...createFindTool(cwd),
647
740
  label: zh.tools.find.label,
648
741
 
649
- renderCall(args: any, theme: any, _context: any) {
742
+ renderCall(args: any, theme: any) {
650
743
  let text = theme.fg("toolTitle", theme.bold(zh.tools.find.title));
651
744
  text += theme.fg("accent", args.pattern);
652
745
  if (args.path) text += theme.fg("dim", ` · ${args.path}`);
653
746
  return new Text(text, 0, 0);
654
747
  },
655
748
 
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
- },
749
+ renderResult: countResultRender(
750
+ {
751
+ searching: zh.tools.find.searching,
752
+ noMatch: zh.tools.find.noFiles,
753
+ found: zh.tools.find.results,
754
+ limit: zh.tools.find.resultLimit,
755
+ truncated: zh.tools.grep.truncated,
756
+ },
757
+ "No files found matching pattern",
758
+ "resultLimitReached",
759
+ ),
691
760
  });
692
761
  }
693
762
 
694
763
  if (builtinTools.has("ls")) {
695
- const originalLs = createLsTool(cwd);
696
764
  pi.registerTool({
697
- ...originalLs,
765
+ ...createLsTool(cwd),
698
766
  label: zh.tools.ls.label,
699
767
 
700
- renderCall(args: any, theme: any, _context: any) {
768
+ renderCall(args: any, theme: any) {
701
769
  let text = theme.fg("toolTitle", theme.bold(zh.tools.ls.title));
702
770
  text += theme.fg("accent", args.path || ".");
703
771
  return new Text(text, 0, 0);
704
772
  },
705
773
 
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
- },
774
+ renderResult: countResultRender(
775
+ {
776
+ searching: zh.tools.ls.listing,
777
+ noMatch: zh.tools.ls.empty,
778
+ found: zh.tools.ls.entries,
779
+ limit: zh.tools.ls.entryLimit,
780
+ truncated: zh.tools.grep.truncated,
781
+ },
782
+ "(empty directory)",
783
+ "entryLimitReached",
784
+ ),
741
785
  });
742
786
  }
743
787
 
744
788
  if (builtinTools.has("powershell")) {
745
- const originalPowerShell = createPowerShellTool(cwd);
746
789
  pi.registerTool({
747
- ...originalPowerShell,
790
+ ...createPowerShellTool(cwd),
748
791
  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
- },
792
+ ...shellRenders(zh.tools.powershell, [/only available on windows/i]),
813
793
  });
814
794
  }
815
795
 
@@ -44,6 +44,8 @@ export const zh = {
44
44
  interrupt: "中断",
45
45
  clearExit: "清空/退出",
46
46
  commands: "命令菜单",
47
+ more: "更多",
48
+ expandHelp: "按下上述按键查看完整启动帮助与已加载资源",
47
49
  onboarding:
48
50
  "Pi 能解释自身的功能、查阅自己的文档——想知道怎么用、怎么扩展,直接问它。",
49
51
  },
@@ -122,6 +124,45 @@ export const zh = {
122
124
  truncated: "[输出过长,已截断]",
123
125
  },
124
126
  },
127
+
128
+ // 项目信任弹窗(复刻内置“信任/不信任”选项;“信任父文件夹”变体不支持)
129
+ trust: {
130
+ title: (cwd: string) =>
131
+ `是否信任项目目录:${cwd}?将允许 pi 加载项目配置并执行项目扩展`,
132
+ options: {
133
+ trustAndRemember: "信任并记住此决定",
134
+ trustSessionOnly: "仅本次会话信任",
135
+ distrustAndRemember: "不信任并记住此决定",
136
+ distrustSessionOnly: "本次会话不信任",
137
+ },
138
+ },
139
+
140
+ // 内置斜杠命令的中文描述(key 为命令名;带参数提示的条目已按 core 的
141
+ // `hint — description` 合成格式写好,避免运行时拼字符串)
142
+ builtinCommands: {
143
+ settings: "打开设置菜单",
144
+ model: "<provider/model> — 选择模型(打开选择器界面)",
145
+ tree: "浏览会话树(切换分支)",
146
+ thinking: "<level> — 设置思考等级",
147
+ "scoped-models": "启用/停用 Ctrl+P 轮换用的模型",
148
+ export: "导出会话(默认 HTML,可指定 .html/.jsonl 路径)",
149
+ import: "从 JSONL 文件导入并恢复会话",
150
+ share: "通过 GitHub 私密 gist 分享会话",
151
+ copy: "复制最后一条助手消息到剪贴板",
152
+ name: "设置会话显示名称",
153
+ session: "显示会话信息与统计",
154
+ changelog: "显示更新日志",
155
+ hotkeys: "显示全部快捷键",
156
+ fork: "从历史用户消息创建新分叉",
157
+ clone: "在当前位置复制当前会话",
158
+ trust: "保存项目信任决定以便后续会话使用",
159
+ login: "<provider> — 配置提供商认证",
160
+ logout: "移除提供商认证",
161
+ new: "开始新会话",
162
+ compact: "手动压缩会话上下文",
163
+ resume: "恢复另一个会话",
164
+ reload: "重新加载快捷键、扩展、技能、提示词、主题和上下文文件",
165
+ quit: "退出 pi",
166
+ },
125
167
  } as const;
126
168
 
127
- export type Zh = typeof zh;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zh-cn",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "pi 编码代理的简体中文界面汉化插件",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -23,6 +23,6 @@
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^26.3.0",
26
- "typescript": "^7.0.2"
26
+ "typescript": "^5.9.3"
27
27
  }
28
28
  }