pi-cc-extensions 0.8.26 → 0.8.28

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.
@@ -0,0 +1,20 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import agentAutocomplete from "./agent-autocomplete.ts";
3
+ import askUserQuestion from "./ask-user-question/index.ts";
4
+ import claudeCodeStyle from "./claude-code-style.ts";
5
+ import context from "./context.ts";
6
+ import piAliases from "./pi-aliases.ts";
7
+ import piStartupHeader from "./pi-startup-header.ts";
8
+ import sessionReference from "./session-reference.ts";
9
+ import subagentNotification from "./subagent-notification.ts";
10
+
11
+ export default function (pi: ExtensionAPI): void {
12
+ piAliases(pi);
13
+ piStartupHeader(pi);
14
+ claudeCodeStyle(pi);
15
+ subagentNotification(pi);
16
+ context(pi);
17
+ sessionReference(pi);
18
+ agentAutocomplete(pi);
19
+ askUserQuestion(pi);
20
+ }
@@ -0,0 +1,114 @@
1
+ import { CustomMessageComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ const PATCH_KEY = Symbol.for("pi.ccstyle.subagent-notification-patch");
5
+ const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
6
+
7
+ type Patch = {
8
+ active: boolean;
9
+ prototype: any;
10
+ originalRender: (width: number) => string[];
11
+ installedRender: (width: number) => string[];
12
+ };
13
+
14
+ function plain(line: string): string {
15
+ return line.replace(ANSI_RE, "");
16
+ }
17
+
18
+ function trimBlankLines(lines: string[]): string[] {
19
+ let start = 0;
20
+ let end = lines.length;
21
+ while (start < end && !plain(lines[start]!).trim()) start++;
22
+ while (end > start && !plain(lines[end - 1]!).trim()) end--;
23
+ return lines.slice(start, end);
24
+ }
25
+
26
+ function normalizeStatusGlyph(line: string): string {
27
+ return line.replace(
28
+ /^((?:\x1b\[[0-?]*[ -/]*[@-~]|[ \t]|[├└│─])*)[✓✔]((?:\x1b\[[0-?]*[ -/]*[@-~])*)(?=\s)/,
29
+ "$1●$2",
30
+ );
31
+ }
32
+
33
+ function isHeader(line: string): boolean {
34
+ return /^[✓✔✗■●]\s+/.test(plain(line).trimStart());
35
+ }
36
+
37
+ function isDetail(line: string): boolean {
38
+ const text = plain(line).trimStart();
39
+ return (
40
+ text.startsWith("⎿") ||
41
+ text.startsWith("transcript:") ||
42
+ text === "No output." ||
43
+ /^(?:Done|Wrapped up|Stopped|Error:|Aborted)\b/.test(text)
44
+ );
45
+ }
46
+
47
+ function cleanDetail(line: string): string {
48
+ const marker = line.indexOf("⎿");
49
+ if (marker >= 0) return line.slice(marker + 1).replace(/^\s+/, "");
50
+ return line.replace(/^\s+/, "");
51
+ }
52
+
53
+ function formatGroup(lines: string[]): string[] {
54
+ if (!lines.length) return [];
55
+ const header = normalizeStatusGlyph(lines[0]!);
56
+ const rest = lines.slice(1);
57
+ const detailStart = rest.findIndex(isDetail);
58
+ if (detailStart < 0) return [header, ...rest];
59
+ const metadata = rest.slice(0, detailStart);
60
+ const details = rest
61
+ .slice(detailStart)
62
+ .map(cleanDetail)
63
+ .filter((line) => plain(line).trim());
64
+ return [
65
+ header,
66
+ ...metadata,
67
+ ...details.map((line, index) => `${index === details.length - 1 ? "└" : "├"} ${line}`),
68
+ ];
69
+ }
70
+
71
+ export function formatSubagentNotificationLines(lines: string[], width: number): string[] {
72
+ const groups: string[][] = [];
73
+ for (const line of trimBlankLines(lines)) {
74
+ if (isHeader(line) && groups.length && groups.at(-1)!.length) groups.push([]);
75
+ if (!groups.length) groups.push([]);
76
+ groups.at(-1)!.push(line);
77
+ }
78
+ return groups.flatMap((group, index) => {
79
+ const formatted = formatGroup(group).map((line) =>
80
+ truncateToWidth(line ? ` ${line}` : line, Math.max(1, width), ""),
81
+ );
82
+ return index ? ["", ...formatted] : formatted;
83
+ });
84
+ }
85
+
86
+ export default function subagentNotificationExtension(pi: ExtensionAPI): void {
87
+ const prototype = CustomMessageComponent.prototype as any;
88
+ const host = globalThis as any;
89
+ const previous = host[PATCH_KEY] as Patch | undefined;
90
+ if (previous) previous.active = false;
91
+ const originalRender =
92
+ previous && prototype.render === previous.installedRender
93
+ ? previous.originalRender
94
+ : prototype.render;
95
+ const patch = {
96
+ active: true,
97
+ prototype,
98
+ originalRender,
99
+ installedRender: undefined as any,
100
+ } satisfies Patch;
101
+ patch.installedRender = function (this: any, width: number): string[] {
102
+ const lines = originalRender.call(this, width);
103
+ if (!patch.active || this?.message?.customType !== "subagent-notification") return lines;
104
+ return formatSubagentNotificationLines(lines, width);
105
+ };
106
+ prototype.render = patch.installedRender;
107
+ host[PATCH_KEY] = patch;
108
+
109
+ pi.on("session_shutdown", async () => {
110
+ if (!patch.active) return;
111
+ patch.active = false;
112
+ if (prototype.render === patch.installedRender) prototype.render = originalRender;
113
+ });
114
+ }
@@ -30,8 +30,10 @@ import {
30
30
  resolveDiffPresentationMode,
31
31
  type DiffPresentationMode,
32
32
  } from "./diff-presentation.ts";
33
+ import { sanitizeToolResultText } from "../tool-result-sanitize.ts";
33
34
  import { pluralize, sanitizeAnsiForThemedOutput } from "./render-utils.ts";
34
35
  import { splitWriteContentLines } from "./write-display-utils.ts";
36
+ import { MAX_HL_CHARS, shikiHighlightCache } from "./shiki-highlight.ts";
35
37
  import {
36
38
  DEFAULT_TOOL_DISPLAY_CONFIG,
37
39
  type DiffIndicatorMode,
@@ -119,9 +121,10 @@ interface DiffRenderOptions {
119
121
  previousContent?: string;
120
122
  fileExistedBeforeWrite?: boolean;
121
123
  headerLabel?: string;
124
+ invalidate?: () => void;
122
125
  }
123
126
 
124
- type CodeLineHighlighter = (line: string) => string;
127
+ type CodeLineHighlighter = (line: string, entry: DiffLineEntry) => string;
125
128
 
126
129
  const CANONICAL_LINE_PATTERN = /^([+\- ])(\s*\d+)\|(.*)$/;
127
130
  const HASHLINE_ANCHOR_LINE_PATTERN = /^([+\- ])(\s*\d+)#([A-Za-z0-9]+| {2}):(.*)$/;
@@ -277,41 +280,75 @@ function resolveLanguageFromPath(rawPath: string | undefined): string | undefine
277
280
  }
278
281
  }
279
282
 
280
- const MAX_CODE_HIGHLIGHT_CACHE_ENTRIES = 2048;
283
+ function resolveShikiTheme(theme: DiffTheme): string {
284
+ if (process.env.DIFF_THEME) return process.env.DIFF_THEME;
285
+ const background =
286
+ parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ??
287
+ parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg")) ??
288
+ parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg"));
289
+ if (!background) return "github-dark";
290
+ const luminance = (background.r * 299 + background.g * 587 + background.b * 114) / 1000;
291
+ return luminance >= 128 ? "github-light" : "github-dark";
292
+ }
281
293
 
282
- function createCodeLineHighlighter(language: string | undefined): CodeLineHighlighter {
283
- if (!language) {
284
- return (line) => sanitizeAnsiForThemedOutput(line);
285
- }
294
+ function cleanCodeLine(line: string): string {
295
+ return sanitizeToolResultText(line).replace(/\n/g, "");
296
+ }
297
+
298
+ export function shouldHighlightCodeBlock(code: string): boolean {
299
+ return code.length <= MAX_HL_CHARS;
300
+ }
286
301
 
287
- // Insertion-order Map used as a small LRU so large diffs cannot retain every unique line.
288
- const cache = new Map<string, string>();
289
- const remember = (line: string, value: string): string => {
290
- if (cache.has(line)) cache.delete(line);
291
- cache.set(line, value);
292
- if (cache.size > MAX_CODE_HIGHLIGHT_CACHE_ENTRIES) {
293
- const oldest = cache.keys().next().value;
294
- if (oldest !== undefined) cache.delete(oldest);
302
+ function createCodeLineHighlighter(
303
+ language: string | undefined,
304
+ theme: DiffTheme,
305
+ entries: readonly ParsedDiffEntry[],
306
+ invalidate?: () => void,
307
+ ): CodeLineHighlighter {
308
+ const codeEntries = entries.filter((entry): entry is DiffLineEntry => entry.kind === "line");
309
+ const cleanLines = codeEntries.map((entry) =>
310
+ cleanCodeLine(normalizeCodeWhitespace(entry.content)),
311
+ );
312
+ const code = cleanLines.join("\n");
313
+ const shouldHighlight = !!language && shouldHighlightCodeBlock(code);
314
+ const fallbackLines = shouldHighlight
315
+ ? (() => {
316
+ try {
317
+ return highlightCode(code, language).map(sanitizeAnsiForThemedOutput);
318
+ } catch {
319
+ return cleanLines.map(sanitizeAnsiForThemedOutput);
320
+ }
321
+ })()
322
+ : cleanLines.map(sanitizeAnsiForThemedOutput);
323
+ const fallbackByEntry = new WeakMap(
324
+ codeEntries.map((entry, index) => [entry, fallbackLines[index] ?? cleanLines[index] ?? ""]),
325
+ );
326
+ if (!shouldHighlight) return (_line, entry) => fallbackByEntry.get(entry) ?? "";
327
+
328
+ const shikiTheme = resolveShikiTheme(theme);
329
+ let highlightedByEntry: WeakMap<DiffLineEntry, string> | undefined;
330
+ const resolveHighlighted = () => {
331
+ if (highlightedByEntry) return;
332
+ const highlighted = shikiHighlightCache.get(
333
+ code,
334
+ language,
335
+ shikiTheme,
336
+ fallbackLines,
337
+ invalidate,
338
+ );
339
+ if (highlighted) {
340
+ highlightedByEntry = new WeakMap(
341
+ codeEntries.map((entry, index) => [
342
+ entry,
343
+ sanitizeAnsiForThemedOutput(highlighted[index] ?? fallbackByEntry.get(entry) ?? ""),
344
+ ]),
345
+ );
295
346
  }
296
- return value;
297
347
  };
298
- return (line) => {
299
- if (!line) {
300
- return line;
301
- }
302
- const cached = cache.get(line);
303
- if (cached !== undefined) {
304
- // Refresh recency for LRU.
305
- cache.delete(line);
306
- cache.set(line, cached);
307
- return cached;
308
- }
309
- try {
310
- const highlighted = highlightCode(line, language)[0] ?? line;
311
- return remember(line, sanitizeAnsiForThemedOutput(highlighted));
312
- } catch {
313
- return remember(line, sanitizeAnsiForThemedOutput(line));
314
- }
348
+ resolveHighlighted();
349
+ return (_line, entry) => {
350
+ resolveHighlighted();
351
+ return highlightedByEntry?.get(entry) ?? fallbackByEntry.get(entry) ?? "";
315
352
  };
316
353
  }
317
354
 
@@ -1104,6 +1141,10 @@ function parseAnsiColorCode(ansi: string | undefined): RgbColor | null {
1104
1141
  }
1105
1142
  }
1106
1143
 
1144
+ const basicMatch = /\x1b\[(?:3|4)([0-7])m/.exec(ansi);
1145
+ if (basicMatch) return ansi256ToRgb(Number(basicMatch[1]));
1146
+ const brightMatch = /\x1b\[(?:9|10)([0-7])m/.exec(ansi);
1147
+ if (brightMatch) return ansi256ToRgb(Number(brightMatch[1]) + 8);
1107
1148
  return null;
1108
1149
  }
1109
1150
 
@@ -1696,7 +1737,7 @@ function highlightDiffLine(
1696
1737
  highlightLine: CodeLineHighlighter,
1697
1738
  containerBgAnsi: string | undefined,
1698
1739
  ): { highlighted: string; rowBg: string | undefined } {
1699
- const syntaxHighlighted = highlightLine(codeText);
1740
+ const syntaxHighlighted = highlightLine(codeText, entry);
1700
1741
  const rowBg = getLineBackground(entry.lineKind, palette, false);
1701
1742
  const emphasisBg = getLineBackground(entry.lineKind, palette, true);
1702
1743
  const inlineSpans = inlineHighlights.get(entry) ?? [];
@@ -2133,15 +2174,7 @@ function renderHeaderRows(
2133
2174
  return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
2134
2175
  }
2135
2176
 
2136
- const summaryPieces =
2137
- mode === "split"
2138
- ? [...buildDiffSummaryBasePieces(stats, theme), theme.fg("muted", mode)]
2139
- : [
2140
- ...buildDiffSummaryBasePieces(stats, theme),
2141
- theme.fg("muted", `${stats.hunks} ${pluralize(stats.hunks, "hunk")}`),
2142
- theme.fg("muted", `${stats.files} ${pluralize(stats.files, "file")}`),
2143
- theme.fg("muted", mode),
2144
- ];
2177
+ const summaryPieces = [...buildDiffSummaryBasePieces(stats, theme), theme.fg("muted", mode)];
2145
2178
 
2146
2179
  const summary = summaryPieces.join(mode === "split" ? " " : theme.fg("muted", " • "));
2147
2180
  const meter = renderDiffStatBar(stats, width, theme);
@@ -2421,12 +2454,13 @@ export function renderEditDiffResult(
2421
2454
  theme: DiffTheme,
2422
2455
  fallbackText: string,
2423
2456
  ): Component {
2424
- const diffText = safeGetDiff(details);
2457
+ const diffText = sanitizeToolResultText(safeGetDiff(details));
2458
+ const safeFallbackText = sanitizeToolResultText(fallbackText);
2425
2459
  if (!diffText.trim()) {
2426
- if (!fallbackText.trim()) {
2460
+ if (!safeFallbackText.trim()) {
2427
2461
  return new Text(theme.fg("muted", "↳ edit completed (no diff payload)"), 0, 0);
2428
2462
  }
2429
- return new Text(theme.fg("toolOutput", fallbackText), 0, 0);
2463
+ return new Text(theme.fg("toolOutput", safeFallbackText), 0, 0);
2430
2464
  }
2431
2465
 
2432
2466
  let parsed: ParsedDiff;
@@ -2451,9 +2485,11 @@ export function renderEditDiffResult(
2451
2485
  // separator cannot leak toolSuccessBg across the entire new column.
2452
2486
  const containerBgAnsi = undefined;
2453
2487
  const language = resolveLanguageFromPath(options.filePath);
2454
- const highlightLine = createCodeLineHighlighter(language);
2455
-
2456
2488
  const cache = createDiffRenderCache();
2489
+ const highlightLine = createCodeLineHighlighter(language, theme, parsed.entries, () => {
2490
+ cache.invalidate();
2491
+ options.invalidate?.();
2492
+ });
2457
2493
 
2458
2494
  return {
2459
2495
  render(width: number): string[] {
@@ -2795,18 +2831,19 @@ export function renderWriteDiffResult(
2795
2831
  theme: DiffTheme,
2796
2832
  fallbackText: string,
2797
2833
  ): Component {
2834
+ const safeFallbackText = sanitizeToolResultText(fallbackText);
2798
2835
  if (typeof content !== "string") {
2799
- if (!fallbackText.trim()) {
2836
+ if (!safeFallbackText.trim()) {
2800
2837
  return new Text(theme.fg("muted", "↳ write completed"), 0, 0);
2801
2838
  }
2802
- return new Text(theme.fg("toolOutput", fallbackText), 0, 0);
2839
+ return new Text(theme.fg("toolOutput", safeFallbackText), 0, 0);
2803
2840
  }
2804
2841
 
2805
2842
  const filePath = options.filePath?.trim() || "(unknown path)";
2806
- const lines = splitWriteContentLines(content);
2843
+ const lines = splitWriteContentLines(sanitizeToolResultText(content));
2807
2844
  const previousLines =
2808
2845
  typeof options.previousContent === "string"
2809
- ? splitWriteContentLines(options.previousContent)
2846
+ ? splitWriteContentLines(sanitizeToolResultText(options.previousContent))
2810
2847
  : [];
2811
2848
  const hasComparablePrevious =
2812
2849
  options.fileExistedBeforeWrite === true && typeof options.previousContent === "string";
@@ -2819,10 +2856,9 @@ export function renderWriteDiffResult(
2819
2856
  // Keep the panel transparent; changed rows and inline spans retain their own highlights.
2820
2857
  const containerBgAnsi = undefined;
2821
2858
  const language = resolveLanguageFromPath(filePath);
2822
- const highlightLine = createCodeLineHighlighter(language);
2823
-
2824
- let detailedData: WriteDiffData | undefined;
2825
2859
  const cache = createDiffRenderCache();
2860
+ let highlightLine: CodeLineHighlighter | undefined;
2861
+ let detailedData: WriteDiffData | undefined;
2826
2862
 
2827
2863
  function getDetailedData(): WriteDiffData {
2828
2864
  if (detailedData) {
@@ -2832,6 +2868,10 @@ export function renderWriteDiffResult(
2832
2868
  ? buildWriteOverwriteEntries(previousLines, lines)
2833
2869
  : buildWriteEntries(lines);
2834
2870
  detailedData = buildWriteDiffData(entries);
2871
+ highlightLine = createCodeLineHighlighter(language, theme, entries, () => {
2872
+ cache.invalidate();
2873
+ options.invalidate?.();
2874
+ });
2835
2875
  return detailedData;
2836
2876
  }
2837
2877
 
@@ -2921,7 +2961,7 @@ export function renderWriteDiffResult(
2921
2961
  theme,
2922
2962
  inlineHighlights,
2923
2963
  palette,
2924
- highlightLine,
2964
+ highlightLine: highlightLine!,
2925
2965
  containerBgAnsi,
2926
2966
  wordWrap,
2927
2967
  indicatorMode,
@@ -47,7 +47,7 @@ export function renderRichToolResult(
47
47
  if (toolName === "edit") {
48
48
  return renderEditDiffResult(
49
49
  result?.details,
50
- { expanded, filePath },
50
+ { expanded, filePath, invalidate: () => context?.invalidate?.() },
51
51
  displayConfig,
52
52
  theme,
53
53
  resultText(result),
@@ -69,6 +69,7 @@ export function renderRichToolResult(
69
69
  filePath,
70
70
  previousContent: metadata.previousContent,
71
71
  fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
72
+ invalidate: () => context?.invalidate?.(),
72
73
  },
73
74
  displayConfig,
74
75
  theme,
@@ -0,0 +1,90 @@
1
+ export const MAX_HL_CHARS = 32_000;
2
+ const MAX_CACHE_ENTRIES = 48;
3
+
4
+ type CodeToAnsi = (code: string, lang: any, theme: any) => Promise<string>;
5
+ type Loader = () => Promise<CodeToAnsi>;
6
+ type Subscriber = { fallback: readonly string[]; invalidate: () => void };
7
+ type Pending = { work: Promise<void>; subscribers: Set<Subscriber> };
8
+
9
+ let defaultLoad: Promise<CodeToAnsi> | undefined;
10
+ const loadDefault: Loader = () => {
11
+ if (!defaultLoad) {
12
+ defaultLoad = import("@shikijs/cli")
13
+ .then((module) => module.codeToANSI as CodeToAnsi)
14
+ .catch((error) => {
15
+ defaultLoad = undefined;
16
+ throw error;
17
+ });
18
+ }
19
+ return defaultLoad;
20
+ };
21
+
22
+ function differs(lines: readonly string[], fallback: readonly string[]): boolean {
23
+ return lines.length !== fallback.length || lines.some((line, index) => line !== fallback[index]);
24
+ }
25
+
26
+ export class ShikiHighlightCache {
27
+ private readonly cache = new Map<string, string[]>();
28
+ private readonly pending = new Map<string, Pending>();
29
+ private readonly loader: Loader;
30
+
31
+ constructor(loader: Loader = loadDefault) {
32
+ this.loader = loader;
33
+ }
34
+
35
+ get(
36
+ code: string,
37
+ lang: string | undefined,
38
+ theme: string,
39
+ fallback: readonly string[],
40
+ invalidate?: () => void,
41
+ ): string[] | undefined {
42
+ if (!lang || code.length > MAX_HL_CHARS) return undefined;
43
+ const key = `${theme}\0${lang}\0${code}`;
44
+ const cached = this.cache.get(key);
45
+ if (cached) {
46
+ this.cache.delete(key);
47
+ this.cache.set(key, cached);
48
+ return cached;
49
+ }
50
+
51
+ const existing = this.pending.get(key);
52
+ if (existing) {
53
+ if (invalidate && ![...existing.subscribers].some((item) => item.invalidate === invalidate)) {
54
+ existing.subscribers.add({ fallback, invalidate });
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ const pending: Pending = { work: undefined as never, subscribers: new Set() };
60
+ if (invalidate) pending.subscribers.add({ fallback, invalidate });
61
+ pending.work = this.loader()
62
+ .then((highlight) => highlight(code, lang, theme))
63
+ .then((ansi) => {
64
+ const lines = ansi.replace(/\r/g, "").replace(/\n$/, "").split("\n");
65
+ this.cache.set(key, lines);
66
+ while (this.cache.size > MAX_CACHE_ENTRIES) {
67
+ const oldest = this.cache.keys().next().value;
68
+ if (oldest !== undefined) this.cache.delete(oldest);
69
+ }
70
+ for (const subscriber of pending.subscribers) {
71
+ if (!differs(lines, subscriber.fallback)) continue;
72
+ try {
73
+ subscriber.invalidate();
74
+ } catch {
75
+ // One component must not prevent the other subscribers from repainting.
76
+ }
77
+ }
78
+ })
79
+ .catch(() => {})
80
+ .finally(() => this.pending.delete(key));
81
+ this.pending.set(key, pending);
82
+ return undefined;
83
+ }
84
+
85
+ clear(): void {
86
+ this.cache.clear();
87
+ }
88
+ }
89
+
90
+ export const shikiHighlightCache = new ShikiHighlightCache();