pi-extensions-i18n 0.4.1 → 0.6.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
@@ -14,6 +14,8 @@ Independent Pi extensions still need the same operational pieces: a portable con
14
14
  - `/config:language` interactive command, plus `/config:language en-US` direct selection.
15
15
  - Catalog loading and validation requiring both language entries for every message key.
16
16
  - Translator interpolation for user-facing UI, command descriptions, and agent prompts.
17
+ - A single notice outlet, `notifyWithSource`, that draws every user-visible notice as a filled background block in the transcript (the same block Pi uses for extension messages) with a short source tag in the package's own label colour. Pi renders `info` notices as dim, unprefixed text, so without the block and tag you cannot tell which extension spoke.
18
+ - Notices land below the message and stay out of the LLM context: they are written as Pi custom entries (`appendEntry` + `registerEntryRenderer`) and only affect the transcript.
17
19
 
18
20
  ## Install
19
21
 
@@ -50,8 +52,24 @@ PI_EXTENSIONS_LOCALE=en-US pi
50
52
  ```
51
53
 
52
54
  ## Extension author API
55
+ The package exports the locale and catalog primitives used by the feature packages, plus the shared notice outlet:
53
56
 
54
- The package exports the locale and catalog primitives used by the feature packages:
57
+ ```ts
58
+ import { notifyWithSource, type NoticeColor, type NoticeSource } from "pi-extensions-i18n";
59
+
60
+ /** Short, unique notice tag for this package. */
61
+ const NOTICE_TAG = "distill";
62
+ /** Label colour; keep it distinct from sibling packages. */
63
+ const NOTICE_COLOR: NoticeColor = "muted";
64
+ /** This package's notice source. */
65
+ const NOTICE_SOURCE: NoticeSource = { tag: NOTICE_TAG, color: NOTICE_COLOR };
66
+
67
+ notifyWithSource({ ctx, source: NOTICE_SOURCE, level: "warning", message: i18n.t("failed") });
68
+ ```
69
+
70
+ This renders as a filled background block with `[distill] message` on its first line: the tag carries the package colour, the body colour follows `level` (`warning` yellow, `error` red, `info` the extension message text colour), and `textColor` overrides the body colour for verdict-style lines that carry their own semantic colour. In tui mode the notice is written as a Pi custom entry below the message; rpc/print/json keep using `ctx.ui.notify` with plain `[distill] message` text so no ANSI leaks into other frontends. The block is registered once by this package's own extension entry, so a package that uses the helper must load `../pi-extensions-i18n/index.ts` in its `pi.extensions` list. Use `formatNotice({ source, message, mode, theme })` when you only need the rendered string.
71
+
72
+ Other exports:
55
73
 
56
74
  ```ts
57
75
  import {
package/README.zh-CN.md CHANGED
@@ -14,6 +14,8 @@ Pi 扩展公共国际化运行时。它提供基于 catalog 的小型 API,支
14
14
  - 提供 `/config:language` 交互式命令,也支持 `/config:language en-US` 直接设置。
15
15
  - 加载并校验 catalog,要求每个消息 key 同时提供两种语言。
16
16
  - 为 UI、命令描述和 Agent prompt 提供用户文案插值。
17
+ - 提供统一的用户提示出口 `notifyWithSource`:把提示画成会话区里的**带底色消息块**(用户消息同款底色,见下条),并配上「来源标签 + 固定颜色」,解决 Pi 对 `info` 级提示只显示暗灰无前缀文本、用户既分不清来源也不容易注意到的问题。
18
+ - 提示落在消息下方、不进 LLM 上下文:通过 Pi 的自定义条目(`appendEntry` + `registerEntryRenderer`)实现,条目只在本地渲染,不消耗上下文窗口。
17
19
 
18
20
  ## 安装
19
21
 
@@ -23,6 +25,31 @@ pi install npm:pi-extensions-i18n
23
25
 
24
26
  各功能包会自动安装并加载这个公共依赖,因此安装任意使用它的功能包即可使用语言命令。只有不安装其他功能包、想单独使用语言命令时,才需要直接安装本包。
25
27
 
28
+ ## 统一的提示出口
29
+
30
+ ```ts
31
+ import { notifyWithSource, type NoticeColor, type NoticeSource } from "pi-extensions-i18n";
32
+
33
+ /** 本扩展的提示标签;短且唯一。 */
34
+ const NOTICE_TAG = "distill";
35
+ /** 提示标签颜色;与其它扩展错开。 */
36
+ const NOTICE_COLOR: NoticeColor = "muted";
37
+ /** 本扩展的提示来源。 */
38
+ const NOTICE_SOURCE: NoticeSource = { tag: NOTICE_TAG, color: NOTICE_COLOR };
39
+
40
+ notifyWithSource({ ctx, source: NOTICE_SOURCE, level: "warning", message: i18n.t("failed") });
41
+ ```
42
+
43
+ 输出是一个和用户消息同款的**实心底色块**,首行是 `[distill] 提示正文`:标签按扩展固定色,正文颜色由 `level` 决定(`warning` 黄、`error` 红、`info` 用扩展消息正文色),也可以用 `textColor` 覆盖成结论行自带的语义色(`dim`/`success` 等)。
44
+
45
+ 渲染细节:
46
+
47
+ - 只有 TUI 会把提示画成底色块;rpc/print/json 仍走 `ctx.ui.notify`,输出纯文本 `[distill] 提示正文`,不会出现 ANSI 乱码。
48
+ - 底色块由本包的扩展入口 `installNoticeRenderer(pi)` 注册一次,因此使用本包的功能包必须在自己的 `pi.extensions` 里加载 `../pi-extensions-i18n/index.ts`。
49
+ - 老版本 Pi 没有这两个能力时不会注入,提示自动退回 `ctx.ui.notify`(仍然可见,只是没有底色)。
50
+
51
+ 需要更细粒度控制时用 `formatNotice({ source, message, mode, theme })` 只取文本。
52
+
26
53
  安装后重新加载 Pi:
27
54
 
28
55
  ```text
package/SKILL.md CHANGED
@@ -20,3 +20,18 @@ description: "配置 Pi 扩展共享语言并排查 locale 优先级、持久化
20
20
  ## 验证
21
21
 
22
22
  执行语言命令后观察下一个使用共享 i18n 的扩展文案。若环境变量存在,它会覆盖持久化值;必须先报告这个覆盖关系,不能反复改 JSON。修改 catalog 时,每个 key 必须同时有 `zh-CN` 与 `en-US`,缺失翻译应作为加载错误修复,不能静默 fallback。
23
+
24
+ ## 统一提示出口
25
+
26
+ 功能包的用户可见提示必须走 `notifyWithSource`(而不是直接 `ctx.ui.notify`),否则 Pi 会把 `info` 级提示渲染成暗灰无前缀的一行文字,用户既分不清来源也不容易注意到:
27
+
28
+ ```ts
29
+ notifyWithSource({ ctx, source: NOTICE_SOURCE, level: "warning", message: i18n.t("failed") });
30
+ ```
31
+
32
+ - 呈现:TUI 下画成会话区里的带底色消息块(落在消息下方,不进 LLM 上下文);rpc/print/json 仍走 `ctx.ui.notify` 的纯文本。
33
+ - 标签:每个包用短的唯一 tag 与固定颜色;颜色只在 tui 模式添加(自动处理,不要自己在调用点拼 ANSI)。
34
+ - 依赖:底色块的渲染器由本包的扩展入口 `installNoticeRenderer(pi)` 注册一次,所以用它的包必须在 `pi.extensions` 里加载 `../pi-extensions-i18n/index.ts`,否则提示会退回纯文本(不报错、不丢提示)。
35
+ - 自带语义色的结论行(如「已打断,未判定」)用 `textColor` 传色,不要再自己写页脚状态行。
36
+
37
+ 排查提示显示问题时,先确认包声明里加载了 i18n 入口,再看调用是否走了这个出口,最后看 tag 与 level 是否合理。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensions-i18n",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Shared i18n catalog loader & translator for pi extensions",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -17,7 +17,7 @@
17
17
  "tsconfig.json"
18
18
  ],
19
19
  "scripts": {
20
- "test": "tsx --test tests/pi-i18n.test.ts",
20
+ "test": "tsx --test tests/*.test.ts",
21
21
  "typecheck": "tsc --noEmit --pretty false",
22
22
  "build": "npm run typecheck",
23
23
  "check": "npm run typecheck && npm test && npm pack --dry-run --json > /dev/null"
@@ -54,10 +54,12 @@
54
54
  "coding-agent"
55
55
  ],
56
56
  "peerDependencies": {
57
- "@earendil-works/pi-coding-agent": ">=0.80.0"
57
+ "@earendil-works/pi-coding-agent": ">=0.80.0",
58
+ "@earendil-works/pi-tui": ">=0.80.0"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@earendil-works/pi-coding-agent": "0.85.1",
62
+ "@earendil-works/pi-tui": "0.85.1",
61
63
  "@types/node": "24.12.4",
62
64
  "tsx": "4.23.1",
63
65
  "typescript": "5.9.3"
package/src/index.ts CHANGED
@@ -6,6 +6,12 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
9
+ import {
10
+ installNoticeRenderer,
11
+ notifyWithSource,
12
+ type NoticeColor,
13
+ type NoticeSource,
14
+ } from "./notice.ts";
9
15
 
10
16
  export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const;
11
17
  export type Locale = (typeof SUPPORTED_LOCALES)[number];
@@ -190,20 +196,32 @@ const commandMessages = loadCatalog(
190
196
  new URL("../locales/command.json", import.meta.url),
191
197
  );
192
198
 
199
+ /** 本扩展的提示标签;短且唯一,便于在会话里定位来源。 */
200
+ const NOTICE_TAG = "language";
201
+ /** 提示标签颜色;与其它扩展错开。 */
202
+ const NOTICE_COLOR: NoticeColor = "accent";
203
+ /** 本扩展的提示来源。 */
204
+ const NOTICE_SOURCE: NoticeSource = { tag: NOTICE_TAG, color: NOTICE_COLOR };
205
+
193
206
  function registerLocaleCommand(pi: ExtensionAPI): void {
194
207
  const i18n = createTranslator(commandMessages);
195
208
  const command = {
196
209
  description: i18n.t("description"),
197
210
  handler: async (args: string, ctx: ExtensionCommandContext) => {
198
211
  if (!ctx.hasUI) {
199
- ctx.ui.notify(i18n.t("noUi"), "warning");
212
+ notifyWithSource({ ctx, source: NOTICE_SOURCE, level: "warning", message: i18n.t("noUi") });
200
213
  return;
201
214
  }
202
215
 
203
216
  const requested = args.trim();
204
217
  const directPreference = requested ? parseLocalePreference(requested) : undefined;
205
218
  if (requested && !directPreference) {
206
- ctx.ui.notify(i18n.t("invalid", { value: requested }), "error");
219
+ notifyWithSource({
220
+ ctx,
221
+ source: NOTICE_SOURCE,
222
+ level: "error",
223
+ message: i18n.t("invalid", { value: requested }),
224
+ });
207
225
  return;
208
226
  }
209
227
 
@@ -238,12 +256,19 @@ function registerLocaleCommand(pi: ExtensionAPI): void {
238
256
  const overrideNotice = envOverride
239
257
  ? `\n${i18n.t("envOverride", { env: LOCALE_ENV })}`
240
258
  : "";
241
- ctx.ui.notify(
242
- `${i18n.t("saved", { locale: preference })}${overrideNotice}\n${configPath}`,
243
- "info",
244
- );
259
+ notifyWithSource({
260
+ ctx,
261
+ source: NOTICE_SOURCE,
262
+ level: "info",
263
+ message: `${i18n.t("saved", { locale: preference })}${overrideNotice}\n${configPath}`,
264
+ });
245
265
  } catch (error) {
246
- ctx.ui.notify(i18n.t("failed", { error: String(error) }), "error");
266
+ notifyWithSource({
267
+ ctx,
268
+ source: NOTICE_SOURCE,
269
+ level: "error",
270
+ message: i18n.t("failed", { error: String(error) }),
271
+ });
247
272
  }
248
273
  },
249
274
  };
@@ -253,5 +278,29 @@ function registerLocaleCommand(pi: ExtensionAPI): void {
253
278
  }
254
279
 
255
280
  export default function piI18n(pi: ExtensionAPI): void {
281
+ // 提示改走会话区的自定义条目(带底色消息块),渲染器在这里一次性注册。
282
+ installNoticeRenderer(pi);
256
283
  registerLocaleCommand(pi);
257
284
  }
285
+
286
+ export {
287
+ formatNotice,
288
+ notifyWithSource,
289
+ installNoticeRenderer,
290
+ hasNoticeRenderer,
291
+ resetNoticeRenderer,
292
+ renderNoticeEntry,
293
+ noticeBodyColor,
294
+ NOTICE_BACKGROUND_COLOR,
295
+ NOTICE_COLOR_MODE,
296
+ NOTICE_ENTRY_TYPE,
297
+ type NoticeApi,
298
+ type NoticeColor,
299
+ type NoticeContext,
300
+ type NoticeEntryData,
301
+ type NoticeEntryTheme,
302
+ type NoticeLevel,
303
+ type NoticeRenderOptions,
304
+ type NoticeSendOptions,
305
+ type NoticeSource,
306
+ } from "./notice.ts";
package/src/notice.ts ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * 用户可见提示的统一呈现:把提示画成会话区里的「带底色消息块」,并标出来源扩展。
3
+ *
4
+ * 背景:Pi 的 ui.notify(info) 只是一行暗灰色文字(warning/error 才有黄色/红色前缀),
5
+ * 各扩展全用 info 时用户在会话里既分不清来源,也分不清哪条是提示。
6
+ *
7
+ * 做法:提示改走 Pi 的自定义条目(appendEntry + registerEntryRenderer),
8
+ * 渲染成和用户消息同款的实心底色块(主题色 customMessageBg),左侧标注来源扩展的短标签。
9
+ * 这些条目不进入 LLM 上下文,只影响会话区外观。
10
+ *
11
+ * 本模块只用结构化类型,不直接依赖 Pi 的实现,便于独立测试。
12
+ */
13
+ import { Box, Text, type Component } from "@earendil-works/pi-tui";
14
+
15
+ /** 允许使用的提示级别;同时是运行时校验的唯一真值来源。 */
16
+ const NOTICE_LEVELS = ["info", "warning", "error"] as const;
17
+
18
+ /** 提示级别,与 Pi 的 ui.notify 类型一致。 */
19
+ export type NoticeLevel = (typeof NOTICE_LEVELS)[number];
20
+
21
+ /** 允许使用的主题色名(Pi 主题色的子集);同时是运行时校验的唯一真值来源。 */
22
+ const NOTICE_COLORS = [
23
+ "accent",
24
+ "success",
25
+ "warning",
26
+ "error",
27
+ "muted",
28
+ "dim",
29
+ "text",
30
+ "customMessageText",
31
+ "toolTitle",
32
+ ] as const;
33
+
34
+ /** 提示用到的主题色名。 */
35
+ export type NoticeColor = (typeof NOTICE_COLORS)[number];
36
+
37
+ /** 一个扩展的提示来源:短标签 + 固定颜色。 */
38
+ export interface NoticeSource {
39
+ /** 展示在消息前的短标签,例如 "naming"。建议用包名去掉 pi- 前缀。 */
40
+ tag: string;
41
+ /** 该扩展的固定标签颜色,用来在会话里快速定位来源。 */
42
+ color: NoticeColor;
43
+ }
44
+
45
+ /** 渲染提示所需的最小 UI 上下文。 */
46
+ export interface NoticeContext {
47
+ /** 运行模式;只有 tui 会渲染成带底色的消息块。 */
48
+ mode?: string;
49
+ ui: {
50
+ /** Pi 的提示出口;非 TUI 模式仍走这里。 */
51
+ notify(message: string, type?: NoticeLevel): void;
52
+ /** 主题;缺失时不加颜色。 */
53
+ theme?: { fg(color: NoticeColor, text: string): string };
54
+ };
55
+ }
56
+
57
+ /** 一次提示的渲染输入。 */
58
+ export interface NoticeRenderOptions {
59
+ /** 来源标签与颜色。 */
60
+ source: NoticeSource;
61
+ /** 提示正文(已本地化)。 */
62
+ message: string;
63
+ /** 运行模式;非 tui 时输出纯文本。 */
64
+ mode: string | undefined;
65
+ /** 主题;缺失时输出纯文本。 */
66
+ theme: NoticeContext["ui"]["theme"];
67
+ }
68
+
69
+ /** 一次带来源的提示调用。 */
70
+ export interface NoticeSendOptions {
71
+ /** 目标 UI 上下文;运行模式与主题从它上面读取。 */
72
+ ctx: NoticeContext;
73
+ /** 来源标签与颜色。 */
74
+ source: NoticeSource;
75
+ /** 提示级别;决定正文颜色(warning 黄、error 红、info 用正文色)。 */
76
+ level: NoticeLevel;
77
+ /** 提示正文(已本地化)。 */
78
+ message: string;
79
+ /** 正文颜色覆盖;例如判定结论行自带语义色(dim/success)时用它。 */
80
+ textColor?: NoticeColor;
81
+ /** 展开时才显示的细节行,平时只占一行,避免刷屏。 */
82
+ details?: string[];
83
+ }
84
+
85
+ /** 只有 TUI 模式能安全地看到 ANSI 颜色。 */
86
+ export const NOTICE_COLOR_MODE = "tui";
87
+
88
+ /** 提示条目的类型名;所有扩展共用一种,渲染器只需注册一次。 */
89
+ export const NOTICE_ENTRY_TYPE = "pi-extensions-notice";
90
+
91
+ /** 提示块的底色主题色:和 Pi 的扩展消息同款,视觉效果接近输入框。 */
92
+ export const NOTICE_BACKGROUND_COLOR = "customMessageBg";
93
+
94
+ /** 标签与消息之间的分隔符。 */
95
+ const TAG_SEPARATOR = " ";
96
+
97
+ /** 落进会话的提示条目数据;渲染器只依赖这些字段,重启后也能原样重建。 */
98
+ export interface NoticeEntryData {
99
+ /** 来源短标签。 */
100
+ tag: string;
101
+ /** 标签颜色。 */
102
+ color: NoticeColor;
103
+ /** 提示级别;决定正文默认颜色。 */
104
+ level: NoticeLevel;
105
+ /** 提示正文。 */
106
+ message: string;
107
+ /** 正文颜色覆盖。 */
108
+ textColor?: NoticeColor;
109
+ /** 展开时才显示的细节行(Ctrl+O 展开工具输出时一起展开)。 */
110
+ details?: string[];
111
+ }
112
+
113
+ /** 渲染器拿到的主题:只需要前景色与底色。 */
114
+ export interface NoticeEntryTheme {
115
+ /** 前景色。 */
116
+ fg(color: NoticeColor, text: string): string;
117
+ /** 底色。 */
118
+ bg(color: typeof NOTICE_BACKGROUND_COLOR, text: string): string;
119
+ }
120
+
121
+ /** 提示渲染所需的 Pi 能力:写入自定义条目 + 注册条目渲染器。 */
122
+ export interface NoticeApi {
123
+ /** 追加一条不进 LLM 上下文的自定义条目。 */
124
+ appendEntry(customType: string, data?: unknown): void;
125
+ /** 注册自定义条目的 TUI 渲染器;Pi 会把展开状态一起传进来。 */
126
+ registerEntryRenderer(
127
+ customType: string,
128
+ renderer: (
129
+ entry: { data?: unknown },
130
+ options: { expanded?: boolean },
131
+ theme: NoticeEntryTheme,
132
+ ) => Component,
133
+ ): void;
134
+ }
135
+
136
+ /** 判断一个未知值是不是普通对象。 */
137
+ function isRecord(value: unknown): value is Record<string, unknown> {
138
+ return typeof value === "object" && value !== null;
139
+ }
140
+
141
+ /** 运行时校验主题色名。 */
142
+ function isNoticeColor(value: unknown): value is NoticeColor {
143
+ return typeof value === "string" && NOTICE_COLORS.some((color) => color === value);
144
+ }
145
+
146
+ /** 运行时校验提示级别。 */
147
+ function isNoticeLevel(value: unknown): value is NoticeLevel {
148
+ return typeof value === "string" && NOTICE_LEVELS.some((level) => level === value);
149
+ }
150
+
151
+ /**
152
+ * 当前会话的提示出口。
153
+ *
154
+ * 这是本模块唯一的可变状态,注入点是扩展入口的 installNoticeRenderer:
155
+ * 提示调用点分散在 15 个包的几十处(含 tps、turn-elapsed 等拿不到 pi 的模块),
156
+ * 逐个传参会把 Pi 的写入能力扩散到所有业务函数里,因此只在入口注入一次。
157
+ * 扩展重载会重新执行入口,这里始终保存最近一次的 Pi 实例。
158
+ */
159
+ let noticeApi: NoticeApi | undefined;
160
+
161
+ /**
162
+ * 注入提示出口并注册条目渲染器。
163
+ * 由 pi-extensions-i18n 的扩展入口调用;依赖它的扩展会自动带上这个入口。
164
+ * 老版本 Pi 没有这两个能力时直接不注入,提示会退回 ui.notify(仍然可见,只是没有底色)。
165
+ */
166
+ /** 提示块的水平内边距:让文字不贴边。 */
167
+ const NOTICE_PADDING_X = 1;
168
+ /** 提示块的垂直内边距:0 表示只占一行,避免提示刷屏。 */
169
+ const NOTICE_PADDING_Y = 0;
170
+
171
+ /**
172
+ * 注册提示条目渲染器,并记下用于写入条目的 Pi 实例。
173
+ * 由 pi-extensions-i18n 的扩展入口调用;依赖它的扩展会自动带上这个入口。
174
+ * 老版本 Pi 没有这两个能力时直接不注入,提示会退回 ui.notify(仍然可见,只是没有底色)。
175
+ */
176
+ export function installNoticeRenderer(api: NoticeApi): void {
177
+ if (typeof api.appendEntry !== "function" || typeof api.registerEntryRenderer !== "function") {
178
+ return;
179
+ }
180
+ api.registerEntryRenderer(NOTICE_ENTRY_TYPE, (entry, options, theme) =>
181
+ renderNoticeEntry(entry, theme, isExpanded(options)));
182
+ noticeApi = api;
183
+ }
184
+
185
+ /** 当前是否已具备把提示画成带底色消息块的能力。 */
186
+ export function hasNoticeRenderer(): boolean {
187
+ return noticeApi !== undefined;
188
+ }
189
+
190
+ /** 测试与重载用:清掉已注入的提示出口。 */
191
+ export function resetNoticeRenderer(): void {
192
+ noticeApi = undefined;
193
+ }
194
+
195
+ /** 正文默认颜色:warning 黄、error 红、info 用扩展消息正文色。 */
196
+ export function noticeBodyColor(level: NoticeLevel, textColor?: NoticeColor): NoticeColor {
197
+ if (textColor !== undefined) return textColor;
198
+ if (level === "warning") return "warning";
199
+ if (level === "error") return "error";
200
+ return "customMessageText";
201
+ }
202
+
203
+ /**
204
+ * 把未知的条目数据收敛成提示条目数据。
205
+ * 逐字段运行时校验;缺失或类型不符时给出可读兜底,不信任外来数据。
206
+ */
207
+ function readNoticeEntryData(input: unknown): NoticeEntryData {
208
+ const raw = isRecord(input) && isRecord(input.data) ? input.data : {};
209
+ const tag = typeof raw.tag === "string" && raw.tag !== "" ? raw.tag : "notice";
210
+ const message = typeof raw.message === "string" ? raw.message : "";
211
+ return {
212
+ tag,
213
+ color: isNoticeColor(raw.color) ? raw.color : "muted",
214
+ level: isNoticeLevel(raw.level) ? raw.level : "info",
215
+ message,
216
+ textColor: isNoticeColor(raw.textColor) ? raw.textColor : undefined,
217
+ details: readDetails(raw.details),
218
+ };
219
+ }
220
+
221
+ /** 只保留非空字符串细节行,避免渲染出空气泡。 */
222
+ function readDetails(value: unknown): string[] | undefined {
223
+ if (!Array.isArray(value)) return undefined;
224
+ const lines = value.filter((line): line is string => typeof line === "string" && line.trim() !== "");
225
+ return lines.length > 0 ? lines : undefined;
226
+ }
227
+
228
+ /** 判断渲染器是否处于展开状态(Ctrl+O);缺省视为收起。 */
229
+ function isExpanded(options: unknown): boolean {
230
+ return isRecord(options) && options.expanded === true;
231
+ }
232
+
233
+ /**
234
+ * 把一个提示条目渲染成带底色的消息块。
235
+ *
236
+ * 默认只占一行(上下不加空白),避免提示刷屏;细节行只在展开(Ctrl+O)时追加。
237
+ * 这里直接构造 pi-tui 的 Box/Text:带底色消息块的排版(整块铺底色、按宽度换行)
238
+ * 由 pi-tui 提供,Pi 自带的扩展消息渲染也是同样写法,属于有意为之的绑定。
239
+ */
240
+ export function renderNoticeEntry(
241
+ input: unknown,
242
+ theme: NoticeEntryTheme,
243
+ expanded = false,
244
+ ): Component {
245
+ const data = readNoticeEntryData(input);
246
+ const label = theme.fg(data.color, `[${data.tag}]`);
247
+ const body = theme.fg(noticeBodyColor(data.level, data.textColor), data.message);
248
+ const box = new Box(NOTICE_PADDING_X, NOTICE_PADDING_Y, (text) => theme.bg(NOTICE_BACKGROUND_COLOR, text));
249
+ box.addChild(new Text(`${label}${TAG_SEPARATOR}${body}`, 0, 0));
250
+ if (expanded && data.details !== undefined) {
251
+ for (const line of data.details) {
252
+ box.addChild(new Text(theme.fg("dim", line), 0, 0));
253
+ }
254
+ }
255
+ return box;
256
+ }
257
+
258
+ /**
259
+ * 给提示文本加上来源标签与颜色。
260
+ * 非 TUI 模式或没有主题时返回纯文本,避免把 ANSI 序列转发给前端。
261
+ */
262
+ export function formatNotice(options: NoticeRenderOptions): string {
263
+ const { source, message, mode, theme } = options;
264
+ const tag = `[${source.tag}]`;
265
+ if (mode !== NOTICE_COLOR_MODE || theme === undefined) return `${tag}${TAG_SEPARATOR}${message}`;
266
+ return `${theme.fg(source.color, tag)}${TAG_SEPARATOR}${message}`;
267
+ }
268
+
269
+ /**
270
+ * 统一的提示出口。
271
+ *
272
+ * TUI:写一条自定义条目,由 registerEntryRenderer 画成带底色的消息块。
273
+ * 其它模式(RPC/print/json):仍走 ui.notify,行为与改造前一致。
274
+ * 条目写入失败时退回 ui.notify,保证提示不会因为渲染方式而丢失。
275
+ */
276
+ export function notifyWithSource(options: NoticeSendOptions): void {
277
+ const { ctx, source, level, message, textColor, details } = options;
278
+ if (ctx.mode === NOTICE_COLOR_MODE && noticeApi !== undefined) {
279
+ const data: NoticeEntryData = {
280
+ tag: source.tag,
281
+ color: source.color,
282
+ level,
283
+ message,
284
+ textColor,
285
+ details,
286
+ };
287
+ try {
288
+ noticeApi.appendEntry(NOTICE_ENTRY_TYPE, data);
289
+ return;
290
+ } catch {
291
+ // 落到下面的 ui.notify:提示照常可见,只是没有底色。
292
+ }
293
+ }
294
+ const text = formatNotice({ source, message, mode: ctx.mode, theme: ctx.ui.theme });
295
+ ctx.ui.notify(text, level);
296
+ }