pi-cc-extensions 0.8.25 → 0.8.27
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 +209 -94
- package/extensions/context.ts +48 -30
- 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 +444 -0
- package/extensions/tool-loading-icon.ts +9 -0
- package/extensions/tool-result-sanitize.ts +2 -2
- package/package.json +3 -8
package/extensions/context.ts
CHANGED
|
@@ -2,14 +2,9 @@ import {
|
|
|
2
2
|
type ExtensionAPI,
|
|
3
3
|
type ExtensionCommandContext,
|
|
4
4
|
estimateTokens,
|
|
5
|
+
getMarkdownTheme,
|
|
5
6
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
7
|
-
Key,
|
|
8
|
-
matchesKey,
|
|
9
|
-
truncateToWidth,
|
|
10
|
-
visibleWidth,
|
|
11
|
-
wrapTextWithAnsi,
|
|
12
|
-
} from "@earendil-works/pi-tui";
|
|
7
|
+
import { Key, Markdown, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
8
|
|
|
14
9
|
export type ContextPart = {
|
|
15
10
|
label: string;
|
|
@@ -33,24 +28,18 @@ function normalizePreviewText(text: string): string {
|
|
|
33
28
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
export
|
|
37
|
-
get(width: number): string[];
|
|
38
|
-
invalidate(): void;
|
|
39
|
-
} {
|
|
40
|
-
let cachedWidth: number | undefined;
|
|
41
|
-
let cachedLines: string[] | undefined;
|
|
31
|
+
export type DialogBounds = { left: number; top: number; width: number };
|
|
42
32
|
|
|
33
|
+
/** 1-based terminal hitbox of the [esc] close button on the dialog title row (row 2 of the box). */
|
|
34
|
+
export function escCloseHitbox(bounds: DialogBounds): {
|
|
35
|
+
row: number;
|
|
36
|
+
startCol: number;
|
|
37
|
+
endCol: number;
|
|
38
|
+
} {
|
|
43
39
|
return {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
cachedWidth = width;
|
|
48
|
-
return cachedLines;
|
|
49
|
-
},
|
|
50
|
-
invalidate(): void {
|
|
51
|
-
cachedWidth = undefined;
|
|
52
|
-
cachedLines = undefined;
|
|
53
|
-
},
|
|
40
|
+
row: bounds.top + 2,
|
|
41
|
+
startCol: bounds.left + bounds.width - 5,
|
|
42
|
+
endCol: bounds.left + bounds.width - 1,
|
|
54
43
|
};
|
|
55
44
|
}
|
|
56
45
|
|
|
@@ -65,7 +54,8 @@ export async function showTextPreview(
|
|
|
65
54
|
let scrollOffset = 0;
|
|
66
55
|
let pageSize = 1;
|
|
67
56
|
let totalLines = 1;
|
|
68
|
-
|
|
57
|
+
let escHitbox: { row: number; startCol: number; endCol: number } | undefined;
|
|
58
|
+
const markdownView = new Markdown(content, 0, 0, getMarkdownTheme());
|
|
69
59
|
|
|
70
60
|
const scrollTo = (nextOffset: number): void => {
|
|
71
61
|
scrollOffset = Math.max(0, Math.min(nextOffset, Math.max(0, totalLines - pageSize)));
|
|
@@ -74,7 +64,7 @@ export async function showTextPreview(
|
|
|
74
64
|
|
|
75
65
|
return {
|
|
76
66
|
invalidate() {
|
|
77
|
-
|
|
67
|
+
markdownView.invalidate();
|
|
78
68
|
},
|
|
79
69
|
handleInput(data: string) {
|
|
80
70
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
@@ -91,12 +81,24 @@ export async function showTextPreview(
|
|
|
91
81
|
const mouse = parseSgrMousePacket(data);
|
|
92
82
|
if (mouse?.final !== "M") return;
|
|
93
83
|
const button = mouseBaseButton(mouse.code);
|
|
84
|
+
if (button === 0 && (mouse.code & 32) === 0) {
|
|
85
|
+
if (
|
|
86
|
+
escHitbox &&
|
|
87
|
+
mouse.row === escHitbox.row &&
|
|
88
|
+
mouse.col >= escHitbox.startCol &&
|
|
89
|
+
mouse.col <= escHitbox.endCol
|
|
90
|
+
) {
|
|
91
|
+
done(undefined);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
94
95
|
if (button === 64) scrollTo(scrollOffset - 3);
|
|
95
96
|
else if (button === 65) scrollTo(scrollOffset + 3);
|
|
96
97
|
}
|
|
97
98
|
},
|
|
98
99
|
render(width: number) {
|
|
99
100
|
const inner = Math.max(1, width - 2);
|
|
101
|
+
const escWidth = visibleWidth("[esc]");
|
|
100
102
|
const bodyInner = Math.max(1, inner - 1);
|
|
101
103
|
const bodyWidth = Math.max(1, bodyInner - 1);
|
|
102
104
|
const terminalHeight = Math.max(1, tui.terminal.rows);
|
|
@@ -107,9 +109,13 @@ export async function showTextPreview(
|
|
|
107
109
|
availableHeight,
|
|
108
110
|
);
|
|
109
111
|
pageSize = Math.max(1, viewportHeight - 6);
|
|
110
|
-
const wrapped =
|
|
112
|
+
const wrapped = markdownView.render(bodyWidth);
|
|
111
113
|
totalLines = wrapped.length;
|
|
112
114
|
scrollOffset = Math.min(scrollOffset, Math.max(0, totalLines - pageSize));
|
|
115
|
+
// Centered overlay with margin 2: mirror TUI resolveOverlayLayout for anchor "center".
|
|
116
|
+
const overlayTop = 2 + Math.floor((availableHeight - viewportHeight) / 2);
|
|
117
|
+
const overlayLeft = Math.floor((Math.max(1, tui.terminal.columns) - width) / 2);
|
|
118
|
+
escHitbox = escCloseHitbox({ left: overlayLeft, top: overlayTop, width });
|
|
113
119
|
const visible = wrapped.slice(scrollOffset, scrollOffset + pageSize);
|
|
114
120
|
const border = (text: string) => theme.fg("border", text);
|
|
115
121
|
const padLine = (text: string, lineWidth = inner): string => {
|
|
@@ -136,11 +142,11 @@ export async function showTextPreview(
|
|
|
136
142
|
});
|
|
137
143
|
const start = totalLines === 0 ? 0 : scrollOffset + 1;
|
|
138
144
|
const end = Math.min(totalLines, scrollOffset + pageSize);
|
|
139
|
-
const status = `${start}-${end} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End ·
|
|
145
|
+
const status = `${start}-${end} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End · [esc] close`;
|
|
140
146
|
|
|
141
147
|
return [
|
|
142
148
|
border(`╭${"─".repeat(inner)}╮`),
|
|
143
|
-
`${border("│")}${padLine(` ${theme.bold(theme.fg("accent", title))}
|
|
149
|
+
`${border("│")}${padLine(` ${theme.bold(theme.fg("accent", title))}`, inner - escWidth)}${theme.fg("muted", "[esc]")}${border("│")}`,
|
|
144
150
|
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
145
151
|
...bodyRows,
|
|
146
152
|
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
@@ -392,6 +398,7 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
|
|
|
392
398
|
startCol: number;
|
|
393
399
|
endCol: number;
|
|
394
400
|
}> = [];
|
|
401
|
+
let escHitbox: { row: number; startCol: number; endCol: number } | undefined;
|
|
395
402
|
|
|
396
403
|
const padLine = (text: string, width: number): string => {
|
|
397
404
|
const truncated = truncateToWidth(text, width, "…");
|
|
@@ -428,6 +435,15 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
|
|
|
428
435
|
(mouse.code & 32) !== 0
|
|
429
436
|
)
|
|
430
437
|
return;
|
|
438
|
+
if (
|
|
439
|
+
escHitbox &&
|
|
440
|
+
mouse.row === escHitbox.row &&
|
|
441
|
+
mouse.col >= escHitbox.startCol &&
|
|
442
|
+
mouse.col <= escHitbox.endCol
|
|
443
|
+
) {
|
|
444
|
+
done(undefined);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
431
447
|
const hitbox = previewHitboxes.find(
|
|
432
448
|
(candidate) =>
|
|
433
449
|
mouse.row === candidate.row &&
|
|
@@ -444,6 +460,7 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
|
|
|
444
460
|
},
|
|
445
461
|
render(width: number) {
|
|
446
462
|
const inner = Math.max(1, width - 2);
|
|
463
|
+
const escWidth = visibleWidth("[esc]");
|
|
447
464
|
const percent = contextWindow > 0 ? (used / contextWindow) * 100 : 0;
|
|
448
465
|
const title = theme.bold(theme.fg("accent", "Context Usage"));
|
|
449
466
|
const subtitle = `${formatTokens(used)} / ${formatTokens(contextWindow)} tokens (${percent.toFixed(1)}%)`;
|
|
@@ -480,13 +497,13 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
|
|
|
480
497
|
const border = (text: string) => theme.fg("border", text);
|
|
481
498
|
const lines = [
|
|
482
499
|
border(`╭${"─".repeat(inner)}╮`),
|
|
483
|
-
`${border("│")}${padLine(` ${title} ${theme.fg("muted", subtitle)}`, inner)}${border("│")}`,
|
|
500
|
+
`${border("│")}${padLine(` ${title} ${theme.fg("muted", subtitle)}`, inner - escWidth)}${theme.fg("muted", "[esc]")}${border("│")}`,
|
|
484
501
|
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
485
502
|
`${border("│")}${padLine(` ${segments}`, inner)}${border("│")}`,
|
|
486
503
|
`${border("│")}${" ".repeat(inner)}${border("│")}`,
|
|
487
504
|
...partRows.map((row) => `${border("│")}${row}${border("│")}`),
|
|
488
505
|
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
489
|
-
`${border("│")}${padLine(theme.fg("dim", " ↑↓ select · Click / Enter to preview ·
|
|
506
|
+
`${border("│")}${padLine(theme.fg("dim", " ↑↓ select · Click / Enter to preview · [esc] close"), inner)}${border("│")}`,
|
|
490
507
|
border(`╰${"─".repeat(inner)}╯`),
|
|
491
508
|
];
|
|
492
509
|
|
|
@@ -499,6 +516,7 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
|
|
|
499
516
|
const overlayTop =
|
|
500
517
|
1 + Math.floor((Math.max(1, terminalHeight - 2) - visibleHeight) / 2);
|
|
501
518
|
const overlayLeft = Math.floor((Math.max(1, tui.terminal.columns) - width) / 2);
|
|
519
|
+
escHitbox = escCloseHitbox({ left: overlayLeft, top: overlayTop, width });
|
|
502
520
|
previewHitboxes = visiblePreviews.flatMap((preview) => {
|
|
503
521
|
const partIndex = allParts.findIndex((part) => part.label === preview.label);
|
|
504
522
|
const line = 5 + partIndex;
|
|
@@ -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();
|