pi-zh-cn 0.1.1 → 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 自动安装)
40
+ npm install
52
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) => ({
@@ -264,9 +333,6 @@ export default function (pi: ExtensionAPI) {
264
333
  const contextClause = `${zh.footer.ratioLabel}${pctText}/${fmt(windowSize)}${getAutoCompactSuffix()}`;
265
334
  const contextIndex = clauses.length;
266
335
  clauses.push(contextClause);
267
- const xpIndex =
268
- process.env.PI_EXPERIMENTAL === "1" ? clauses.length : -1;
269
- if (xpIndex >= 0) clauses.push("xp");
270
336
 
271
337
  // 占比按阈值变色(>90% 红、>70% 黄);实验标记沿用 built-in 样式。
272
338
  const statsLeft = clauses
@@ -278,9 +344,6 @@ export default function (pi: ExtensionAPI) {
278
344
  ? theme.fg("warning", clause)
279
345
  : theme.fg("dim", clause);
280
346
  }
281
- if (index === xpIndex) {
282
- return `${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`;
283
- }
284
347
  return theme.fg("dim", clause);
285
348
  })
286
349
  .join(" ");
@@ -376,6 +439,137 @@ export default function (pi: ExtensionAPI) {
376
439
  registerLocalizedTools(ctx.cwd);
377
440
  });
378
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
+
379
573
  function registerLocalizedTools(cwd: string) {
380
574
  const activeTools = pi.getActiveTools();
381
575
  const builtinTools = new Set(
@@ -387,12 +581,11 @@ export default function (pi: ExtensionAPI) {
387
581
 
388
582
  // ---------- 内置工具汉化渲染 ----------
389
583
  // read
390
- const originalRead = createReadTool(cwd);
391
584
  pi.registerTool({
392
- ...originalRead,
585
+ ...createReadTool(cwd),
393
586
  label: zh.tools.read.label,
394
587
 
395
- renderCall(args: any, theme: any, _context: any) {
588
+ renderCall(args: any, theme: any) {
396
589
  let text = theme.fg("toolTitle", theme.bold(`${zh.tools.read.title} `));
397
590
  text += theme.fg("accent", args.path);
398
591
  if (args.offset || args.limit) {
@@ -408,14 +601,10 @@ export default function (pi: ExtensionAPI) {
408
601
  result: any,
409
602
  { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
410
603
  theme: any,
411
- _context: any,
412
604
  ) {
413
- if (isPartial)
414
- return new Text(theme.fg("warning", zh.tools.read.reading), 0, 0);
605
+ if (isPartial) return notePartial(zh.tools.read.reading, theme);
415
606
 
416
- const details = result.details as ReadToolDetails | undefined;
417
607
  const content = result.content[0];
418
-
419
608
  if (content?.type === "image") {
420
609
  return new Text(theme.fg("success", zh.tools.read.imageLoaded), 0, 0);
421
610
  }
@@ -423,103 +612,40 @@ export default function (pi: ExtensionAPI) {
423
612
  return new Text(theme.fg("error", zh.tools.read.noContent), 0, 0);
424
613
  }
425
614
 
426
- const lineCount = content.text.split("\n").length;
427
- let text = theme.fg("success", zh.tools.read.lines(lineCount));
428
-
429
- 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) {
430
622
  text += theme.fg(
431
623
  "warning",
432
- zh.tools.read.truncatedFrom(details.truncation.totalLines),
624
+ zh.tools.read.truncatedFrom(truncation.totalLines),
433
625
  );
434
626
  }
435
-
436
- if (expanded) {
437
- for (const line of content.text.split("\n")) {
438
- text += `\n${theme.fg("dim", line)}`;
439
- }
440
- }
441
-
442
- return new Text(text, 0, 0);
627
+ return new Text(
628
+ withExpanded(expanded ? content.text : "", text, theme),
629
+ 0,
630
+ 0,
631
+ );
443
632
  },
444
633
  });
445
634
 
446
- // bash
447
- const originalBash = createBashTool(cwd);
635
+ // bash / powershell(渲染逻辑同构,仅文案与失败判定集合不同)
448
636
  pi.registerTool({
449
- ...originalBash,
637
+ ...createBashTool(cwd),
450
638
  label: zh.tools.bash.label,
451
-
452
- renderCall(args: any, theme: any, _context: any) {
453
- let text = theme.fg("toolTitle", theme.bold(zh.tools.bash.title));
454
- const cmd: string =
455
- args.command.length > 80
456
- ? `${args.command.slice(0, 77)}...`
457
- : args.command;
458
- text += theme.fg("accent", cmd);
459
- if (args.timeout) {
460
- text += theme.fg("dim", zh.tools.bash.timeout(args.timeout));
461
- }
462
- return new Text(text, 0, 0);
463
- },
464
-
465
- renderResult(
466
- result: any,
467
- { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
468
- theme: any,
469
- _context: any,
470
- ) {
471
- if (isPartial)
472
- return new Text(theme.fg("warning", zh.tools.bash.running), 0, 0);
473
-
474
- const details = result.details as BashToolDetails | undefined;
475
- const content = result.content[0];
476
- const output: string = content?.type === "text" ? content.text : "";
477
-
478
- const exitMatch = output.match(
479
- /(?:exit code:\s*|command exited with code\s+)(\d+)/i,
480
- );
481
- const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : null;
482
- const failed =
483
- exitCode !== null ||
484
- /^error\b/i.test(output) ||
485
- /\n\ncommand (?:timed out|was aborted|failed)/i.test(output);
486
- const lineCount = output.split("\n").filter((l) => l.trim()).length;
487
-
488
- let text = "";
489
- if (!failed) {
490
- text += theme.fg("success", zh.tools.bash.done);
491
- } else {
492
- text += theme.fg(
493
- "error",
494
- exitCode !== null
495
- ? zh.tools.bash.exit(exitCode)
496
- : zh.tools.bash.failed,
497
- );
498
- }
499
- text += theme.fg("dim", ` (${zh.tools.bash.lines(lineCount)})`);
500
-
501
- if (details?.truncation?.truncated) {
502
- text += theme.fg("warning", ` ${zh.tools.bash.truncated}`);
503
- }
504
-
505
- if (expanded) {
506
- for (const line of output.split("\n")) {
507
- text += `\n${theme.fg("dim", line)}`;
508
- }
509
- }
510
-
511
- return new Text(text, 0, 0);
512
- },
639
+ ...shellRenders(zh.tools.bash),
513
640
  });
514
641
 
515
642
  // edit
516
- const originalEdit = createEditTool(cwd);
517
643
  pi.registerTool({
518
- ...originalEdit,
644
+ ...createEditTool(cwd),
519
645
  label: zh.tools.edit.label,
520
646
  renderShell: "self",
521
647
 
522
- renderCall(args: any, theme: any, _context: any) {
648
+ renderCall(args: any, theme: any) {
523
649
  let text = theme.fg("toolTitle", theme.bold(zh.tools.edit.title));
524
650
  text += theme.fg("accent", args.path);
525
651
  return new Text(text, 0, 0);
@@ -529,25 +655,20 @@ export default function (pi: ExtensionAPI) {
529
655
  result: any,
530
656
  { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
531
657
  theme: any,
532
- _context: any,
533
658
  ) {
534
- if (isPartial)
535
- return new Text(theme.fg("warning", zh.tools.edit.editing), 0, 0);
659
+ if (isPartial) return notePartial(zh.tools.edit.editing, theme);
536
660
 
537
- const details = result.details as EditToolDetails | undefined;
538
- const content = result.content[0];
661
+ const err = errorFirstLine(result, theme);
662
+ if (err) return err;
539
663
 
540
- if (content?.type === "text" && content.text.startsWith("Error")) {
541
- return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0);
542
- }
664
+ const details = result.details as EditToolDetails | undefined;
543
665
  if (!details?.diff) {
544
666
  return new Text(theme.fg("success", zh.tools.edit.applied), 0, 0);
545
667
  }
546
668
 
547
- const diffLines: string[] = details.diff.split("\n");
548
669
  let additions = 0,
549
670
  removals = 0;
550
- for (const line of diffLines) {
671
+ for (const line of details.diff.split("\n")) {
551
672
  if (line.startsWith("+") && !line.startsWith("+++")) additions++;
552
673
  if (line.startsWith("-") && !line.startsWith("---")) removals++;
553
674
  }
@@ -556,23 +677,20 @@ export default function (pi: ExtensionAPI) {
556
677
  text += theme.fg("dim", " / ");
557
678
  text += theme.fg("error", zh.tools.edit.removals(removals));
558
679
 
559
- if (expanded) {
560
- for (const line of diffLines) {
561
- text += `\n${theme.fg("dim", line)}`;
562
- }
563
- }
564
-
565
- return new Text(text, 0, 0);
680
+ return new Text(
681
+ withExpanded(expanded ? details.diff : "", text, theme),
682
+ 0,
683
+ 0,
684
+ );
566
685
  },
567
686
  });
568
687
 
569
688
  // write
570
- const originalWrite = createWriteTool(cwd);
571
689
  pi.registerTool({
572
- ...originalWrite,
690
+ ...createWriteTool(cwd),
573
691
  label: zh.tools.write.label,
574
692
 
575
- renderCall(args: any, theme: any, _context: any) {
693
+ renderCall(args: any, theme: any) {
576
694
  let text = theme.fg("toolTitle", theme.bold(zh.tools.write.title));
577
695
  text += theme.fg("accent", args.path);
578
696
  const lineCount = String(args.content).split("\n").length;
@@ -580,31 +698,21 @@ export default function (pi: ExtensionAPI) {
580
698
  return new Text(text, 0, 0);
581
699
  },
582
700
 
583
- renderResult(
584
- result: any,
585
- { isPartial }: { isPartial: boolean },
586
- theme: any,
587
- _context: any,
588
- ) {
589
- if (isPartial)
590
- return new Text(theme.fg("warning", zh.tools.write.writing), 0, 0);
591
-
592
- const content = result.content[0];
593
- if (content?.type === "text" && content.text.startsWith("Error")) {
594
- return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0);
595
- }
596
-
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;
597
705
  return new Text(theme.fg("success", zh.tools.write.written), 0, 0);
598
706
  },
599
707
  });
600
708
 
709
+ // grep / find / ls:调用渲染各自略有差异,结果渲染走同一个计数模板。
601
710
  if (builtinTools.has("grep")) {
602
- const originalGrep = createGrepTool(cwd);
603
711
  pi.registerTool({
604
- ...originalGrep,
712
+ ...createGrepTool(cwd),
605
713
  label: zh.tools.grep.label,
606
714
 
607
- renderCall(args: any, theme: any, _context: any) {
715
+ renderCall(args: any, theme: any) {
608
716
  let text = theme.fg("toolTitle", theme.bold(zh.tools.grep.title));
609
717
  text += theme.fg("accent", args.pattern);
610
718
  if (args.path) text += theme.fg("dim", ` · ${args.path}`);
@@ -612,214 +720,76 @@ export default function (pi: ExtensionAPI) {
612
720
  return new Text(text, 0, 0);
613
721
  },
614
722
 
615
- renderResult(
616
- result: any,
617
- { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
618
- theme: any,
619
- _context: any,
620
- ) {
621
- if (isPartial)
622
- return new Text(theme.fg("warning", zh.tools.grep.searching), 0, 0);
623
-
624
- const details = result.details as GrepToolDetails | undefined;
625
- const content = result.content[0];
626
- const output = content?.type === "text" ? content.text : "";
627
- const noMatches = output === "No matches found";
628
- const count = output
629
- .split("\n")
630
- .filter((line: string) => line.trim()).length;
631
- let text = theme.fg(
632
- noMatches ? "muted" : "success",
633
- noMatches ? zh.tools.grep.noMatches : zh.tools.grep.matches(count),
634
- );
635
- if (details?.matchLimitReached !== undefined) {
636
- text += theme.fg(
637
- "warning",
638
- zh.tools.grep.matchLimit(details.matchLimitReached),
639
- );
640
- }
641
- if (details?.linesTruncated || details?.truncation?.truncated) {
642
- text += theme.fg("warning", zh.tools.grep.truncated);
643
- }
644
- if (expanded && output) {
645
- const expandedOutput = noMatches ? zh.tools.grep.noMatches : output;
646
- text += `\n${theme.fg("dim", expandedOutput)}`;
647
- }
648
- return new Text(text, 0, 0);
649
- },
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
+ ),
650
734
  });
651
735
  }
652
736
 
653
737
  if (builtinTools.has("find")) {
654
- const originalFind = createFindTool(cwd);
655
738
  pi.registerTool({
656
- ...originalFind,
739
+ ...createFindTool(cwd),
657
740
  label: zh.tools.find.label,
658
741
 
659
- renderCall(args: any, theme: any, _context: any) {
742
+ renderCall(args: any, theme: any) {
660
743
  let text = theme.fg("toolTitle", theme.bold(zh.tools.find.title));
661
744
  text += theme.fg("accent", args.pattern);
662
745
  if (args.path) text += theme.fg("dim", ` · ${args.path}`);
663
746
  return new Text(text, 0, 0);
664
747
  },
665
748
 
666
- renderResult(
667
- result: any,
668
- { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
669
- theme: any,
670
- _context: any,
671
- ) {
672
- if (isPartial)
673
- return new Text(theme.fg("warning", zh.tools.find.searching), 0, 0);
674
-
675
- const details = result.details as FindToolDetails | undefined;
676
- const content = result.content[0];
677
- const output = content?.type === "text" ? content.text : "";
678
- const noFiles = output === "No files found matching pattern";
679
- const count = output
680
- .split("\n")
681
- .filter((line: string) => line.trim()).length;
682
- let text = theme.fg(
683
- noFiles ? "muted" : "success",
684
- noFiles ? zh.tools.find.noFiles : zh.tools.find.results(count),
685
- );
686
- if (details?.resultLimitReached !== undefined) {
687
- text += theme.fg(
688
- "warning",
689
- zh.tools.find.resultLimit(details.resultLimitReached),
690
- );
691
- }
692
- if (details?.truncation?.truncated) {
693
- text += theme.fg("warning", zh.tools.grep.truncated);
694
- }
695
- if (expanded && output) {
696
- const expandedOutput = noFiles ? zh.tools.find.noFiles : output;
697
- text += `\n${theme.fg("dim", expandedOutput)}`;
698
- }
699
- return new Text(text, 0, 0);
700
- },
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
+ ),
701
760
  });
702
761
  }
703
762
 
704
763
  if (builtinTools.has("ls")) {
705
- const originalLs = createLsTool(cwd);
706
764
  pi.registerTool({
707
- ...originalLs,
765
+ ...createLsTool(cwd),
708
766
  label: zh.tools.ls.label,
709
767
 
710
- renderCall(args: any, theme: any, _context: any) {
768
+ renderCall(args: any, theme: any) {
711
769
  let text = theme.fg("toolTitle", theme.bold(zh.tools.ls.title));
712
770
  text += theme.fg("accent", args.path || ".");
713
771
  return new Text(text, 0, 0);
714
772
  },
715
773
 
716
- renderResult(
717
- result: any,
718
- { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
719
- theme: any,
720
- _context: any,
721
- ) {
722
- if (isPartial)
723
- return new Text(theme.fg("warning", zh.tools.ls.listing), 0, 0);
724
-
725
- const details = result.details as LsToolDetails | undefined;
726
- const content = result.content[0];
727
- const output = content?.type === "text" ? content.text : "";
728
- const empty = output === "(empty directory)";
729
- const count = output
730
- .split("\n")
731
- .filter((line: string) => line.trim()).length;
732
- let text = theme.fg(
733
- empty ? "muted" : "success",
734
- empty ? zh.tools.ls.empty : zh.tools.ls.entries(count),
735
- );
736
- if (details?.entryLimitReached !== undefined) {
737
- text += theme.fg(
738
- "warning",
739
- zh.tools.ls.entryLimit(details.entryLimitReached),
740
- );
741
- }
742
- if (details?.truncation?.truncated) {
743
- text += theme.fg("warning", zh.tools.grep.truncated);
744
- }
745
- if (expanded && output) {
746
- const expandedOutput = empty ? zh.tools.ls.empty : output;
747
- text += `\n${theme.fg("dim", expandedOutput)}`;
748
- }
749
- return new Text(text, 0, 0);
750
- },
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
+ ),
751
785
  });
752
786
  }
753
787
 
754
788
  if (builtinTools.has("powershell")) {
755
- const originalPowerShell = createPowerShellTool(cwd);
756
789
  pi.registerTool({
757
- ...originalPowerShell,
790
+ ...createPowerShellTool(cwd),
758
791
  label: zh.tools.powershell.label,
759
-
760
- renderCall(args: any, theme: any, _context: any) {
761
- let text = theme.fg(
762
- "toolTitle",
763
- theme.bold(zh.tools.powershell.title),
764
- );
765
- const command =
766
- args.command.length > 80
767
- ? `${args.command.slice(0, 77)}...`
768
- : args.command;
769
- text += theme.fg("accent", command);
770
- if (args.timeout) {
771
- text += theme.fg("dim", zh.tools.powershell.timeout(args.timeout));
772
- }
773
- return new Text(text, 0, 0);
774
- },
775
-
776
- renderResult(
777
- result: any,
778
- { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
779
- theme: any,
780
- _context: any,
781
- ) {
782
- if (isPartial)
783
- return new Text(
784
- theme.fg("warning", zh.tools.powershell.running),
785
- 0,
786
- 0,
787
- );
788
-
789
- const details = result.details as PowerShellToolDetails | undefined;
790
- const content = result.content[0];
791
- const output = content?.type === "text" ? content.text : "";
792
- const exitMatch = output.match(
793
- /(?:exit code:\s*|command exited with code\s+)(\d+)/i,
794
- );
795
- const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : null;
796
- const failed =
797
- exitCode !== null ||
798
- /^error\b/i.test(output) ||
799
- /only available on windows/i.test(output) ||
800
- /\n\ncommand (?:timed out|was aborted|failed)/i.test(output);
801
- const lineCount = output
802
- .split("\n")
803
- .filter((line: string) => line.trim()).length;
804
- let text = theme.fg(
805
- failed ? "error" : "success",
806
- failed
807
- ? exitCode !== null
808
- ? zh.tools.powershell.exit(exitCode)
809
- : zh.tools.powershell.failed
810
- : zh.tools.powershell.done,
811
- );
812
- text += theme.fg("dim", ` (${zh.tools.powershell.lines(lineCount)})`);
813
- if (details?.truncation?.truncated) {
814
- text += theme.fg("warning", ` ${zh.tools.powershell.truncated}`);
815
- }
816
- if (expanded) {
817
- for (const line of output.split("\n")) {
818
- text += `\n${theme.fg("dim", line)}`;
819
- }
820
- }
821
- return new Text(text, 0, 0);
822
- },
792
+ ...shellRenders(zh.tools.powershell, [/only available on windows/i]),
823
793
  });
824
794
  }
825
795
 
@@ -124,6 +124,45 @@ export const zh = {
124
124
  truncated: "[输出过长,已截断]",
125
125
  },
126
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
+ },
127
167
  } as const;
128
168
 
129
- 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.1",
3
+ "version": "0.2.0",
4
4
  "description": "pi 编码代理的简体中文界面汉化插件",
5
5
  "type": "module",
6
6
  "keywords": [