@zigai/pi-message-highlights 0.1.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 ADDED
@@ -0,0 +1,17 @@
1
+ # @zigai/pi-message-highlights
2
+
3
+ Highlights URLs and file paths in Pi's interactive UI:
4
+
5
+ - URLs render blue.
6
+ - File paths render with Pi's accent/highlight color.
7
+ - Applies to assistant responses, past user messages, and the prompt editor.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pi install npm:@zigai/pi-message-highlights
13
+ ```
14
+
15
+ ## License
16
+
17
+ MIT
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@zigai/pi-message-highlights",
3
+ "version": "0.1.0",
4
+ "description": "Pi package that highlights URLs and file paths in messages and the prompt editor.",
5
+ "keywords": [
6
+ "highlighting",
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "pi-extension",
10
+ "pi-package",
11
+ "pi-tweaks",
12
+ "rendering"
13
+ ],
14
+ "homepage": "https://github.com/zigai/pi-tweaks/tree/main/packages/pi-message-highlights#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/zigai/pi-tweaks/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "zigai",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/zigai/pi-tweaks.git",
23
+ "directory": "packages/pi-message-highlights"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "README.md",
28
+ "*.json"
29
+ ],
30
+ "type": "module",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "@earendil-works/pi-tui": "*"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./src/index.ts"
41
+ ]
42
+ }
43
+ }
@@ -0,0 +1,344 @@
1
+ const ESC = String.fromCharCode(0x1b);
2
+ const BEL = String.fromCharCode(0x07);
3
+ const ST = `${ESC}\\`;
4
+ const DEFAULT_FOREGROUND = `${ESC}[39m`;
5
+
6
+ const URL_REGEX = /https?:\/\/[^\s<>"'`]+/g;
7
+ const FILE_EXTENSION_PATTERN = [
8
+ "astro",
9
+ "bash",
10
+ "c",
11
+ "cjs",
12
+ "css",
13
+ "gif",
14
+ "go",
15
+ "gql",
16
+ "graphql",
17
+ "h",
18
+ "html?",
19
+ "ico",
20
+ "java",
21
+ "jpeg",
22
+ "jpg",
23
+ "js",
24
+ "jsonc?",
25
+ "jsx",
26
+ "kt",
27
+ "kts",
28
+ "less",
29
+ "lock",
30
+ "log",
31
+ "mdx?",
32
+ "mjs",
33
+ "php",
34
+ "png",
35
+ "proto",
36
+ "py",
37
+ "rb",
38
+ "rs",
39
+ "sass",
40
+ "scss",
41
+ "sh",
42
+ "sql",
43
+ "sqlite",
44
+ "svg",
45
+ "svelte",
46
+ "swift",
47
+ "toml",
48
+ "tsx?",
49
+ "txt",
50
+ "vue",
51
+ "wasm",
52
+ "webp",
53
+ "ya?ml",
54
+ "zsh",
55
+ ].join("|");
56
+ const EXPLICIT_PATH_PREFIX_PATTERN = String.raw`(?:~/|\.{1,2}/|/)`;
57
+ const PATH_SEGMENT_PATTERN = String.raw`[A-Za-z0-9._~+@%-]+`;
58
+ const PATH_FILENAME_PATTERN = String.raw`${PATH_SEGMENT_PATTERN}\.(?:${FILE_EXTENSION_PATTERN})`;
59
+ const PREFIXED_PATH_PATTERN = String.raw`${EXPLICIT_PATH_PREFIX_PATTERN}${PATH_SEGMENT_PATTERN}(?:/${PATH_SEGMENT_PATTERN})*`;
60
+ const RELATIVE_FILE_PATH_PATTERN = String.raw`${PATH_SEGMENT_PATTERN}(?:/${PATH_SEGMENT_PATTERN})*/${PATH_FILENAME_PATTERN}`;
61
+ const BARE_FILENAME_PATTERN = String.raw`(?:${PATH_FILENAME_PATTERN}|Dockerfile|Justfile|Makefile|\.(?:env|gitignore|npmrc|prettierignore)(?:\.[A-Za-z0-9_-]+)?)`;
62
+ const LINE_SUFFIX_PATTERN = String.raw`(?::\d+(?:-\d+)?(?::\d+)?)?`;
63
+ const FILEPATH_REGEX = new RegExp(
64
+ String.raw`(^|[\s([{<"'\x60])((?:${PREFIXED_PATH_PATTERN}|${RELATIVE_FILE_PATH_PATTERN}|${BARE_FILENAME_PATTERN})${LINE_SUFFIX_PATTERN})(?=$|[\s)\]}>"'\x60,.;:!?])`,
65
+ "g",
66
+ );
67
+ const TRAILING_URL_PUNCTUATION = /[.,;:!?]+$/;
68
+ const TRAILING_PATH_PUNCTUATION = /[.,;!?]+$/;
69
+
70
+ export const URL_BLUE_STYLE = `${ESC}[38;5;117m`;
71
+
72
+ export type HighlightStyles = {
73
+ readonly url: string;
74
+ readonly filepath: string;
75
+ };
76
+
77
+ type TextToken = {
78
+ readonly kind: "text";
79
+ readonly text: string;
80
+ readonly plainStart: number;
81
+ readonly plainEnd: number;
82
+ readonly foreground: string | undefined;
83
+ };
84
+
85
+ type ControlToken = {
86
+ readonly kind: "control";
87
+ readonly text: string;
88
+ };
89
+
90
+ type Token = TextToken | ControlToken;
91
+
92
+ type HighlightRange = {
93
+ readonly start: number;
94
+ readonly end: number;
95
+ readonly style: string;
96
+ };
97
+
98
+ function readEscapeSequence(text: string, start: number): string {
99
+ const introducer = text[start + 1];
100
+ if (introducer === undefined) return text.slice(start, start + 1);
101
+
102
+ if (introducer === "[") {
103
+ for (let index = start + 2; index < text.length; index += 1) {
104
+ const code = text.charCodeAt(index);
105
+ if (code >= 0x40 && code <= 0x7e) return text.slice(start, index + 1);
106
+ }
107
+ return text.slice(start);
108
+ }
109
+
110
+ if (introducer === "]" || introducer === "_" || introducer === "P" || introducer === "^") {
111
+ const belIndex = text.indexOf(BEL, start + 2);
112
+ const stIndex = text.indexOf(ST, start + 2);
113
+ if (belIndex === -1 && stIndex === -1) return text.slice(start);
114
+ if (belIndex !== -1 && (stIndex === -1 || belIndex < stIndex)) {
115
+ return text.slice(start, belIndex + BEL.length);
116
+ }
117
+ return text.slice(start, stIndex + ST.length);
118
+ }
119
+
120
+ return text.slice(start, start + 2);
121
+ }
122
+
123
+ function parseSgrNumbers(sequence: string): number[] | undefined {
124
+ if (!sequence.startsWith(`${ESC}[`) || !sequence.endsWith("m")) return undefined;
125
+
126
+ const params = sequence.slice(2, -1);
127
+ if (params.length === 0) return [0];
128
+
129
+ const numbers: number[] = [];
130
+ for (const param of params.split(";")) {
131
+ if (param.length === 0) {
132
+ numbers.push(0);
133
+ continue;
134
+ }
135
+ const value = Number(param);
136
+ if (!Number.isInteger(value)) return undefined;
137
+ numbers.push(value);
138
+ }
139
+ return numbers;
140
+ }
141
+
142
+ function resolveForegroundAfterSgr(
143
+ sequence: string,
144
+ currentForeground: string | undefined,
145
+ ): string | undefined {
146
+ const numbers = parseSgrNumbers(sequence);
147
+ if (numbers === undefined) return currentForeground;
148
+
149
+ let foreground = currentForeground;
150
+ for (let index = 0; index < numbers.length; index += 1) {
151
+ const code = numbers[index] ?? 0;
152
+ if (code === 0) {
153
+ foreground = undefined;
154
+ } else if (code === 39) {
155
+ foreground = undefined;
156
+ } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
157
+ foreground = `${ESC}[${code}m`;
158
+ } else if (code === 38) {
159
+ const mode = numbers[index + 1];
160
+ if (mode === 5) {
161
+ const color = numbers[index + 2];
162
+ if (color !== undefined) {
163
+ foreground = `${ESC}[38;5;${color}m`;
164
+ index += 2;
165
+ }
166
+ } else if (mode === 2) {
167
+ const red = numbers[index + 2];
168
+ const green = numbers[index + 3];
169
+ const blue = numbers[index + 4];
170
+ if (red !== undefined && green !== undefined && blue !== undefined) {
171
+ foreground = `${ESC}[38;2;${red};${green};${blue}m`;
172
+ index += 4;
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ return foreground;
179
+ }
180
+
181
+ function tokenizeAnsi(text: string): { readonly tokens: Token[]; readonly plainText: string } {
182
+ const tokens: Token[] = [];
183
+ let plainText = "";
184
+ let foreground: string | undefined;
185
+ let index = 0;
186
+
187
+ while (index < text.length) {
188
+ if (text[index] === ESC) {
189
+ const sequence = readEscapeSequence(text, index);
190
+ foreground = resolveForegroundAfterSgr(sequence, foreground);
191
+ tokens.push({ kind: "control", text: sequence });
192
+ index += sequence.length;
193
+ continue;
194
+ }
195
+
196
+ const start = index;
197
+ while (index < text.length && text[index] !== ESC) {
198
+ index += 1;
199
+ }
200
+
201
+ const tokenText = text.slice(start, index);
202
+ tokens.push({
203
+ kind: "text",
204
+ text: tokenText,
205
+ plainStart: plainText.length,
206
+ plainEnd: plainText.length + tokenText.length,
207
+ foreground,
208
+ });
209
+ plainText += tokenText;
210
+ }
211
+
212
+ return { tokens, plainText };
213
+ }
214
+
215
+ function getTrailingPunctuationPattern(kind: "url" | "filepath"): RegExp {
216
+ if (kind === "url") return TRAILING_URL_PUNCTUATION;
217
+ return TRAILING_PATH_PUNCTUATION;
218
+ }
219
+
220
+ function getBracketOpener(closingBracket: string): string {
221
+ if (closingBracket === ")") return "(";
222
+ if (closingBracket === "]") return "[";
223
+ return "{";
224
+ }
225
+
226
+ function trimMatchEnd(text: string, kind: "url" | "filepath"): string {
227
+ const trimmed = text.replace(getTrailingPunctuationPattern(kind), "");
228
+
229
+ let result = trimmed;
230
+ while (result.length > 0) {
231
+ const last = result.at(-1);
232
+ if (last !== ")" && last !== "]" && last !== "}") break;
233
+
234
+ const opener = getBracketOpener(last);
235
+ const openingCount = result.split(opener).length - 1;
236
+ const closingCount = result.split(last).length - 1;
237
+ if (closingCount <= openingCount) break;
238
+ result = result.slice(0, -1);
239
+ }
240
+
241
+ return result;
242
+ }
243
+
244
+ function overlapsRange(ranges: readonly HighlightRange[], start: number, end: number): boolean {
245
+ return ranges.some((range) => start < range.end && end > range.start);
246
+ }
247
+
248
+ function addRange(
249
+ ranges: HighlightRange[],
250
+ start: number,
251
+ rawText: string,
252
+ kind: "url" | "filepath",
253
+ style: string,
254
+ ): void {
255
+ const text = trimMatchEnd(rawText, kind);
256
+ if (text.length === 0) return;
257
+
258
+ const end = start + text.length;
259
+ if (overlapsRange(ranges, start, end)) return;
260
+ ranges.push({ start, end, style });
261
+ }
262
+
263
+ function collectHighlightRanges(plainText: string, styles: HighlightStyles): HighlightRange[] {
264
+ const ranges: HighlightRange[] = [];
265
+
266
+ for (const match of plainText.matchAll(URL_REGEX)) {
267
+ if (match.index === undefined) continue;
268
+ addRange(ranges, match.index, match[0], "url", styles.url);
269
+ }
270
+
271
+ for (const match of plainText.matchAll(FILEPATH_REGEX)) {
272
+ if (match.index === undefined) continue;
273
+ const prefix = match[1] ?? "";
274
+ const filepath = match[2];
275
+ if (filepath === undefined) continue;
276
+ addRange(ranges, match.index + prefix.length, filepath, "filepath", styles.filepath);
277
+ }
278
+
279
+ return ranges.sort((left, right) => left.start - right.start);
280
+ }
281
+
282
+ function restoreForeground(foreground: string | undefined): string {
283
+ return foreground ?? DEFAULT_FOREGROUND;
284
+ }
285
+
286
+ function appendTextWithHighlights(
287
+ output: string[],
288
+ token: TextToken,
289
+ ranges: readonly HighlightRange[],
290
+ ): void {
291
+ let tokenOffset = 0;
292
+
293
+ for (const range of ranges) {
294
+ if (range.end <= token.plainStart) continue;
295
+ if (range.start >= token.plainEnd) break;
296
+
297
+ const highlightStart = Math.max(range.start, token.plainStart) - token.plainStart;
298
+ const highlightEnd = Math.min(range.end, token.plainEnd) - token.plainStart;
299
+
300
+ if (highlightStart > tokenOffset) {
301
+ output.push(token.text.slice(tokenOffset, highlightStart));
302
+ }
303
+
304
+ output.push(range.style);
305
+ output.push(token.text.slice(highlightStart, highlightEnd));
306
+ output.push(restoreForeground(token.foreground));
307
+ tokenOffset = highlightEnd;
308
+ }
309
+
310
+ if (tokenOffset < token.text.length) {
311
+ output.push(token.text.slice(tokenOffset));
312
+ }
313
+ }
314
+
315
+ export function highlightMessageLine(line: string, styles: HighlightStyles): string {
316
+ if (line.length === 0) return line;
317
+
318
+ const { tokens, plainText } = tokenizeAnsi(line);
319
+ const ranges = collectHighlightRanges(plainText, styles);
320
+ if (ranges.length === 0) return line;
321
+
322
+ const output: string[] = [];
323
+ for (const token of tokens) {
324
+ if (token.kind === "control") {
325
+ output.push(token.text);
326
+ } else {
327
+ appendTextWithHighlights(output, token, ranges);
328
+ }
329
+ }
330
+
331
+ return output.join("");
332
+ }
333
+
334
+ export function highlightMessageLines(lines: readonly string[], styles: HighlightStyles): string[] {
335
+ return lines.map((line) => highlightMessageLine(line, styles));
336
+ }
337
+
338
+ export function getStylePrefix(styleFn: (text: string) => string): string {
339
+ const sentinel = "\u0000";
340
+ const styled = styleFn(sentinel);
341
+ const sentinelIndex = styled.indexOf(sentinel);
342
+ if (sentinelIndex < 0) return "";
343
+ return styled.slice(0, sentinelIndex);
344
+ }
package/src/index.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { Editor } from "@earendil-works/pi-tui";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ import {
6
+ getStylePrefix,
7
+ highlightMessageLines,
8
+ URL_BLUE_STYLE,
9
+ type HighlightStyles,
10
+ } from "./highlight-text.ts";
11
+
12
+ const MESSAGE_HIGHLIGHTS_PATCH_KEY = Symbol.for("zigai.pi-message-highlights.patched");
13
+
14
+ type PatchState = typeof globalThis & {
15
+ [MESSAGE_HIGHLIGHTS_PATCH_KEY]?: boolean;
16
+ };
17
+
18
+ type ThemeLike = {
19
+ fg(color: "accent", text: string): string;
20
+ };
21
+
22
+ type HighlightStylesProvider = () => HighlightStyles;
23
+
24
+ type RenderablePrototype = {
25
+ render(this: object, width: number): string[];
26
+ };
27
+
28
+ function getPatchState(): PatchState {
29
+ return globalThis as PatchState;
30
+ }
31
+
32
+ async function resolvePiDistDir(): Promise<string> {
33
+ const codingAgentEntry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
34
+ return dirname(codingAgentEntry);
35
+ }
36
+
37
+ function warnInternalPatchUnavailable(feature: string, error?: unknown): void {
38
+ let suffix = "";
39
+ if (error instanceof Error && error.message.length > 0) {
40
+ suffix = `: ${error.message}`;
41
+ }
42
+ console.warn(
43
+ `[pi-message-highlights] ${feature} unavailable; Pi internals may have changed${suffix}`,
44
+ );
45
+ }
46
+
47
+ async function loadPiTheme(): Promise<ThemeLike | undefined> {
48
+ try {
49
+ const distDir = await resolvePiDistDir();
50
+ const themePath = pathToFileURL(join(distDir, "modes/interactive/theme/theme.js")).href;
51
+ // SAFETY: This imports Pi's own interactive theme module. The exported
52
+ // proxy is intentionally stable across Pi module loaders and exposes
53
+ // Theme.fg once the interactive theme is initialized.
54
+ const themeModule = (await import(themePath)) as { theme?: ThemeLike };
55
+ const theme = themeModule.theme;
56
+ if (theme === undefined) return undefined;
57
+ return {
58
+ fg(color: "accent", text: string): string {
59
+ return theme.fg(color, text);
60
+ },
61
+ };
62
+ } catch (error: unknown) {
63
+ warnInternalPatchUnavailable("theme color lookup", error);
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ async function loadComponentPrototype(
69
+ fileName: string,
70
+ exportName: string,
71
+ ): Promise<RenderablePrototype | undefined> {
72
+ try {
73
+ const distDir = await resolvePiDistDir();
74
+ const componentPath = pathToFileURL(
75
+ join(distDir, "modes/interactive/components", fileName),
76
+ ).href;
77
+ const componentModule = (await import(componentPath)) as Record<string, unknown>;
78
+ const exported = componentModule[exportName];
79
+ if (typeof exported !== "function") {
80
+ warnInternalPatchUnavailable(`${exportName} patch`);
81
+ return undefined;
82
+ }
83
+
84
+ const prototype = Reflect.get(exported, "prototype");
85
+ if (
86
+ typeof prototype === "object" &&
87
+ prototype !== null &&
88
+ typeof Reflect.get(prototype, "render") === "function"
89
+ ) {
90
+ // SAFETY: Runtime checks above verified the render method required
91
+ // by the RenderablePrototype patch seam.
92
+ return prototype as RenderablePrototype;
93
+ }
94
+ warnInternalPatchUnavailable(`${exportName} patch`);
95
+ } catch (error: unknown) {
96
+ warnInternalPatchUnavailable(`${exportName} patch`, error);
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ function buildHighlightStyles(theme: ThemeLike | undefined): HighlightStyles {
102
+ let filepath = "\u001b[38;5;81m";
103
+ if (theme !== undefined) {
104
+ try {
105
+ const prefix = getStylePrefix((text: string) => theme.fg("accent", text));
106
+ if (prefix.length > 0) {
107
+ filepath = prefix;
108
+ }
109
+ } catch {
110
+ // Theme may not be initialized yet during early startup renders.
111
+ // Later renders will retry and pick up the native accent color.
112
+ }
113
+ }
114
+
115
+ return {
116
+ url: URL_BLUE_STYLE,
117
+ filepath,
118
+ };
119
+ }
120
+
121
+ function getEditorPrototype(): RenderablePrototype | undefined {
122
+ const prototype = Editor.prototype as unknown;
123
+ if (
124
+ typeof prototype === "object" &&
125
+ prototype !== null &&
126
+ typeof Reflect.get(prototype, "render") === "function"
127
+ ) {
128
+ // SAFETY: Runtime checks above verified the render method required by
129
+ // patchRenderablePrototype. Editor.prototype is the stable pi-tui seam
130
+ // used by Pi's CustomEditor subclass.
131
+ return prototype as RenderablePrototype;
132
+ }
133
+ warnInternalPatchUnavailable("Editor patch");
134
+ return undefined;
135
+ }
136
+
137
+ function patchRenderablePrototype(
138
+ prototype: RenderablePrototype,
139
+ getStyles: HighlightStylesProvider,
140
+ ): void {
141
+ const originalRender = Reflect.get(prototype, "render") as
142
+ | ((this: object, width: number) => string[])
143
+ | undefined;
144
+ if (typeof originalRender !== "function") return;
145
+
146
+ prototype.render = function highlightedRender(this: object, width: number): string[] {
147
+ return highlightMessageLines(originalRender.call(this, width), getStyles());
148
+ };
149
+ }
150
+
151
+ async function installMessageHighlightPatch(): Promise<void> {
152
+ const state = getPatchState();
153
+ if (state[MESSAGE_HIGHLIGHTS_PATCH_KEY] === true) return;
154
+
155
+ const theme = await loadPiTheme();
156
+ const getStyles = () => buildHighlightStyles(theme);
157
+ const assistantPrototype = await loadComponentPrototype(
158
+ "assistant-message.js",
159
+ "AssistantMessageComponent",
160
+ );
161
+ const userPrototype = await loadComponentPrototype("user-message.js", "UserMessageComponent");
162
+ const editorPrototype = getEditorPrototype();
163
+ if (
164
+ assistantPrototype === undefined ||
165
+ userPrototype === undefined ||
166
+ editorPrototype === undefined
167
+ ) {
168
+ return;
169
+ }
170
+
171
+ patchRenderablePrototype(assistantPrototype, getStyles);
172
+ patchRenderablePrototype(userPrototype, getStyles);
173
+ patchRenderablePrototype(editorPrototype, getStyles);
174
+
175
+ state[MESSAGE_HIGHLIGHTS_PATCH_KEY] = true;
176
+ }
177
+
178
+ export default async function messageHighlightsExtension(): Promise<void> {
179
+ await installMessageHighlightPatch();
180
+ }