pi-cc-extensions 1.0.11 → 1.0.12

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.en.md CHANGED
@@ -46,7 +46,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
46
46
  {
47
47
  // style
48
48
  "mode": "on", // on / compact / off
49
- "excludeRenderers": [], // tools keeping the native renderer; Agent always keeps its dedicated renderer
49
+ "excludeRenderers": [], // tools keeping the native renderer, matched case-insensitively; Agent always keeps its dedicated renderer
50
50
 
51
51
  // features
52
52
  "enableSessionReference": true, // @ session references
package/README.md CHANGED
@@ -46,7 +46,7 @@ pi install git:github.com/minuque/pi-cc-extensions
46
46
  {
47
47
  // style
48
48
  "mode": "on", // on / compact / off
49
- "excludeRenderers": [], // 走原生渲染的工具名;Agent 始终保留专用渲染器
49
+ "excludeRenderers": [], // 走原生渲染的工具名,匹配忽略大小写;Agent 始终保留专用渲染器
50
50
 
51
51
  // features
52
52
  "enableSessionReference": true, // @ session 引用
@@ -340,6 +340,43 @@ export function formatExcludeRenderers(names: readonly string[]): string {
340
340
  return names.length === 0 ? "none" : names.join(", ");
341
341
  }
342
342
 
343
+ /** excludeRenderers 与运行时工具名按大小写不敏感匹配(如候选 "agent" 对上 pi-subagents 的 "Agent")。 */
344
+ export function isExcludedRenderer(
345
+ toolName: string,
346
+ excludeRenderers: readonly string[] = config.excludeRenderers,
347
+ ): boolean {
348
+ const key = toolName.toLowerCase();
349
+ return excludeRenderers.some((name) => name.toLowerCase() === key);
350
+ }
351
+
352
+ /** 写入名单时去掉同名异大小写项,再按 id 写入,避免 agent/Agent 并存。 */
353
+ export function withExcludeRendererToggle(
354
+ excludeRenderers: readonly string[],
355
+ id: string,
356
+ exclude: boolean,
357
+ ): string[] {
358
+ const key = id.toLowerCase();
359
+ const next = excludeRenderers.filter((name) => name.toLowerCase() !== key);
360
+ if (exclude) next.push(id);
361
+ return [...new Set(next)].sort((a, b) => a.localeCompare(b));
362
+ }
363
+
364
+ /** 面板候选:内置候选与用户名单按大小写不敏感合并,保留先出现的写法(候选在前,仍是 agent)。 */
365
+ export function mergeExcludeRendererCandidates(
366
+ names: readonly string[],
367
+ candidates: readonly string[] = EXCLUDE_RENDERER_CANDIDATES,
368
+ ): string[] {
369
+ const seen = new Set<string>();
370
+ const merged: string[] = [];
371
+ for (const name of [...candidates, ...names]) {
372
+ const key = name.toLowerCase();
373
+ if (seen.has(key)) continue;
374
+ seen.add(key);
375
+ merged.push(name);
376
+ }
377
+ return merged.sort((a, b) => a.localeCompare(b));
378
+ }
379
+
343
380
  export function formatConfigStatus(source: Config = config): string {
344
381
  return [
345
382
  `mode=${source.mode}`,
@@ -38,7 +38,6 @@ import {
38
38
  DIFF_INDICATOR_MODES,
39
39
  DIFF_SPLIT_MIN_WIDTH_VALUES,
40
40
  DIFF_VIEW_MODES,
41
- EXCLUDE_RENDERER_CANDIDATES,
42
41
  EXPANDED_INPUT_MAX_LINES_VALUES,
43
42
  EXPANDED_OUTPUT_MAX_LINES_VALUES,
44
43
  EXPANDED_PREVIEW_MAX_LINES_VALUES,
@@ -62,6 +61,9 @@ import {
62
61
  type FooterGitStatsMode,
63
62
  type FooterTokenRateMode,
64
63
  type ScrollButtonMode,
64
+ isExcludedRenderer,
65
+ mergeExcludeRendererCandidates,
66
+ withExcludeRendererToggle,
65
67
  } from "./config.ts";
66
68
 
67
69
  /** renderer 注入的渲染副作用,面板自身不触碰渲染状态。 */
@@ -268,14 +270,13 @@ function buildExcludeRenderersSubmenu(
268
270
  invalidate: () => void;
269
271
  handleInput: (data: string) => void;
270
272
  } {
271
- const candidates = [
272
- ...new Set([...EXCLUDE_RENDERER_CANDIDATES, ...config.excludeRenderers]),
273
- ].sort((a, b) => a.localeCompare(b));
273
+ // 候选与名单按大小写不敏感合并:存过 Agent 时不再多出一行 agent。
274
+ const candidates = mergeExcludeRendererCandidates(config.excludeRenderers);
274
275
  const items = candidates.map((name) => ({
275
276
  id: name,
276
277
  label: name,
277
278
  description: `Use Pi native renderer for ${name} instead of Claude Code styling.`,
278
- currentValue: config.excludeRenderers.includes(name) ? "exclude" : "style",
279
+ currentValue: isExcludedRenderer(name, config.excludeRenderers) ? "exclude" : "style",
279
280
  values: ["style", "exclude"],
280
281
  }));
281
282
  const list = new SettingsList(
@@ -283,10 +284,13 @@ function buildExcludeRenderersSubmenu(
283
284
  Math.min(8, Math.max(4, items.length)),
284
285
  getSettingsListTheme(),
285
286
  (id: string, value: string) => {
286
- const excluded = new Set(config.excludeRenderers);
287
- if (value === "exclude") excluded.add(id);
288
- else excluded.delete(id);
289
- updateConfig({ excludeRenderers: [...excluded].sort((a, b) => a.localeCompare(b)) });
287
+ updateConfig({
288
+ excludeRenderers: withExcludeRendererToggle(
289
+ config.excludeRenderers,
290
+ id,
291
+ value === "exclude",
292
+ ),
293
+ });
290
294
  onLiveChange();
291
295
  },
292
296
  () => onClose(),
@@ -6,7 +6,7 @@
6
6
  * 生命周期对齐 compact-mode:installDefaultMode → hooks.shutdown。
7
7
  */
8
8
  import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
9
- import { config, type CompactStyleMode } from "../config/config.ts";
9
+ import { config, isExcludedRenderer, type CompactStyleMode } from "../config/config.ts";
10
10
  import {
11
11
  COMPONENT_TOOL_RENDER_MODE,
12
12
  GLOBAL_TOOL_RENDER_PATCH,
@@ -27,7 +27,7 @@ export function preservesOriginalRenderer(
27
27
  builtInToolDefinition?: any,
28
28
  excludeRenderers: readonly string[] = config.excludeRenderers,
29
29
  ): boolean {
30
- if (!excludeRenderers.includes(toolName)) return false;
30
+ if (!isExcludedRenderer(toolName, excludeRenderers)) return false;
31
31
  return [extensionDefinition, builtInToolDefinition].some(
32
32
  (definition) =>
33
33
  definition?.renderShell === "self" ||
@@ -45,11 +45,51 @@ export function createCodeLineHighlighter(
45
45
  invalidate?: () => void,
46
46
  ): CodeLineHighlighter {
47
47
  const codeEntries = entries.filter((entry): entry is DiffLineEntry => entry.kind === "line");
48
+ const allCode = codeEntries
49
+ .map((entry) => cleanCodeLine(normalizeCodeWhitespace(entry.content)))
50
+ .join("\n");
51
+ // Budget check stays on the whole visible code: over the limit nothing is highlighted.
52
+ // Per-segment checks would let a huge diff highlight itself one segment at a time.
53
+ const segmentLanguage = shouldHighlightCodeBlock(allCode) ? language : undefined;
54
+ const highlightByEntry = new WeakMap<DiffLineEntry, CodeLineHighlighter>();
55
+ let segment: DiffLineEntry[] = [];
56
+
57
+ const flushSegment = () => {
58
+ if (segment.length === 0) return;
59
+ const highlight = createContiguousCodeLineHighlighter(
60
+ segmentLanguage,
61
+ theme,
62
+ segment,
63
+ invalidate,
64
+ );
65
+ for (const entry of segment) highlightByEntry.set(entry, highlight);
66
+ segment = [];
67
+ };
68
+
69
+ // Omitted source cannot carry lexer state across a diff discontinuity.
70
+ // Each segment is highlighted and cached on its own; the budget above still bounds one diff.
71
+ for (const entry of entries) {
72
+ if (entry.kind === "line") segment.push(entry);
73
+ else flushSegment();
74
+ }
75
+ flushSegment();
76
+
77
+ return (line, entry) => highlightByEntry.get(entry)?.(line, entry) ?? "";
78
+ }
79
+
80
+ function createContiguousCodeLineHighlighter(
81
+ language: string | undefined,
82
+ theme: DiffTheme,
83
+ codeEntries: readonly DiffLineEntry[],
84
+ invalidate?: () => void,
85
+ ): CodeLineHighlighter {
48
86
  const cleanLines = codeEntries.map((entry) =>
49
87
  cleanCodeLine(normalizeCodeWhitespace(entry.content)),
50
88
  );
51
89
  const code = cleanLines.join("\n");
52
- const shouldHighlight = !!language && shouldHighlightCodeBlock(code);
90
+ // A segment is a subset of the visible code, so `segmentLanguage` already carries
91
+ // the total budget decision; only the per-segment highlight is left to decide here.
92
+ const shouldHighlight = language !== undefined;
53
93
  const fallbackLines = shouldHighlight
54
94
  ? (() => {
55
95
  try {
@@ -1,6 +1,5 @@
1
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
1
+ import { createWriteToolDefinition, type WriteOperations } from "@earendil-works/pi-coding-agent";
2
2
  import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { dirname, isAbsolute, resolve } from "node:path";
4
3
 
5
4
  export const MAX_COMPARABLE_WRITE_BYTES = 512_000;
6
5
  export const MAX_WRITE_METADATA_ENTRIES = 100;
@@ -81,33 +80,51 @@ export async function executeWriteWithMetadata(
81
80
  signal: AbortSignal | undefined,
82
81
  cwd: string,
83
82
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }> {
84
- const absolutePath = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
85
83
  store.delete(toolCallId);
84
+ // 覆盖层只揽写前快照:路径解析(含 Windows 下 /mnt/<drive>、/cygdrive/<drive> 转换)、
85
+ // 目录创建、mutation queue 与错误文案全交回 pi 官方 write,参数与官方逐字一致。
86
+ const definition = createWriteToolDefinition(cwd, {
87
+ operations: writeSnapshotOperations(store, toolCallId),
88
+ }) as any;
86
89
  try {
87
- return await withFileMutationQueue(absolutePath, async () => {
88
- const throwIfAborted = () => {
89
- if (signal?.aborted) throw new Error("Operation aborted");
90
- };
91
- throwIfAborted();
92
- const metadata = await capturePreviousContent(absolutePath);
93
- throwIfAborted();
94
- await mkdir(dirname(absolutePath), { recursive: true });
95
- throwIfAborted();
96
- await writeFile(absolutePath, params.content, "utf8");
97
- // The file is already changed. A later abort must not report failure.
98
- store.set(toolCallId, metadata);
99
- return {
100
- content: [
101
- {
102
- type: "text",
103
- text: `Successfully wrote ${Buffer.byteLength(params.content, "utf8")} bytes to ${params.path}`,
104
- },
105
- ],
106
- details: undefined,
107
- };
108
- });
90
+ await definition.execute(toolCallId, params, signal, undefined, { cwd });
91
+ return writeSuccessResult(params);
109
92
  } catch (error) {
93
+ // 文件已落盘,只是官方写后那次 abort 检查抦了错:保持「文件已改即成功」。
94
+ if (store.get(toolCallId) && signal?.aborted) return writeSuccessResult(params);
110
95
  store.delete(toolCallId);
111
96
  throw error;
112
97
  }
113
98
  }
99
+
100
+ /** 官方 write 的落盘操作:拿到的是官方解析后的绝对路径,快照才跟真实落盘一致。 */
101
+ function writeSnapshotOperations(
102
+ store: WriteExecutionMetadataStore,
103
+ toolCallId: string,
104
+ ): WriteOperations {
105
+ return {
106
+ mkdir: (dir) => mkdir(dir, { recursive: true }).then(() => {}),
107
+ writeFile: async (absolutePath, content) => {
108
+ store.delete(toolCallId);
109
+ const metadata = await capturePreviousContent(absolutePath);
110
+ await writeFile(absolutePath, content, "utf8");
111
+ // 写已生效:后续 abort 不得把这次写报成失败,快照先落下。
112
+ store.set(toolCallId, metadata);
113
+ },
114
+ };
115
+ }
116
+
117
+ function writeSuccessResult(params: { path: string; content: string }): {
118
+ content: Array<{ type: "text"; text: string }>;
119
+ details: undefined;
120
+ } {
121
+ return {
122
+ content: [
123
+ {
124
+ type: "text",
125
+ text: `Successfully wrote ${Buffer.byteLength(params.content, "utf8")} bytes to ${params.path}`,
126
+ },
127
+ ],
128
+ details: undefined,
129
+ };
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",