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.
- package/extensions/claude-code-style.ts +322 -104
- package/extensions/index.ts +20 -0
- package/extensions/subagent-notification.ts +114 -0
- package/extensions/tool-diff/diff-renderer.ts +95 -55
- package/extensions/tool-diff/index.ts +2 -1
- package/extensions/tool-diff/shiki-highlight.ts +90 -0
- package/extensions/tool-grouping.ts +492 -0
- package/extensions/tool-loading-icon.ts +9 -0
- package/extensions/tool-result-sanitize.ts +2 -2
- package/package.json +3 -8
|
@@ -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
|
-
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 (!
|
|
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",
|
|
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 (!
|
|
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",
|
|
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();
|