@xynogen/pix-pretty 1.0.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/src/diff.ts ADDED
@@ -0,0 +1,68 @@
1
+ // Structured diff parsing — ported verbatim from @heyhuynhgiabuu/pi-diff
2
+ // (src/core/diff.ts). Wraps the `diff` npm package's structuredPatch into a
3
+ // flat line model the split/unified renderers consume.
4
+
5
+ import * as Diff from "diff";
6
+
7
+ export interface DiffLine {
8
+ type: "add" | "del" | "ctx" | "sep";
9
+ oldNum: number | null;
10
+ newNum: number | null;
11
+ content: string;
12
+ }
13
+
14
+ export interface ParsedDiff {
15
+ lines: DiffLine[];
16
+ added: number;
17
+ removed: number;
18
+ chars: number;
19
+ }
20
+
21
+ export function parseDiff(
22
+ oldContent: string,
23
+ newContent: string,
24
+ ctx = 3,
25
+ ): ParsedDiff {
26
+ const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", {
27
+ context: ctx,
28
+ });
29
+ const lines: DiffLine[] = [];
30
+ let added = 0;
31
+ let removed = 0;
32
+
33
+ for (let hi = 0; hi < patch.hunks.length; hi++) {
34
+ if (hi > 0) {
35
+ const prev = patch.hunks[hi - 1];
36
+ const gap = patch.hunks[hi].oldStart - (prev.oldStart + prev.oldLines);
37
+ lines.push({
38
+ type: "sep",
39
+ oldNum: null,
40
+ newNum: gap > 0 ? gap : null,
41
+ content: "",
42
+ });
43
+ }
44
+ const h = patch.hunks[hi];
45
+ let oL = h.oldStart;
46
+ let nL = h.newStart;
47
+ for (const raw of h.lines) {
48
+ if (raw === "\") continue;
49
+ const ch = raw[0];
50
+ const text = raw.slice(1);
51
+ if (ch === "+") {
52
+ lines.push({ type: "add", oldNum: null, newNum: nL++, content: text });
53
+ added++;
54
+ } else if (ch === "-") {
55
+ lines.push({ type: "del", oldNum: oL++, newNum: null, content: text });
56
+ removed++;
57
+ } else {
58
+ lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text });
59
+ }
60
+ }
61
+ }
62
+ return {
63
+ lines,
64
+ added,
65
+ removed,
66
+ chars: oldContent.length + newContent.length,
67
+ };
68
+ }
package/src/fff.ts ADDED
@@ -0,0 +1,416 @@
1
+ import { spawn } from "node:child_process";
2
+ import { join } from "node:path";
3
+
4
+ import type { GrepCursor, GrepMatch } from "@ff-labs/fff-node";
5
+
6
+ import type {
7
+ ConstraintParseResult,
8
+ FffBackedFinder,
9
+ MultiGrepRipgrepFallbackParams,
10
+ MultiGrepRipgrepFallbackResult,
11
+ OptionalFffModule,
12
+ } from "./types.js";
13
+
14
+ export interface FffState {
15
+ module: OptionalFffModule | null;
16
+ finder: FffBackedFinder | null;
17
+ partialIndex: boolean;
18
+ dbDir: string | null;
19
+ }
20
+
21
+ export const fffState: FffState = {
22
+ module: null,
23
+ finder: null,
24
+ partialIndex: false,
25
+ dbDir: null,
26
+ };
27
+
28
+ export const FFF_SCAN_TIMEOUT = 15_000;
29
+
30
+ export function getPiPrettyFffDir(_agentDir: string): string {
31
+ // FFF state lives under the XDG cache dir, not the agent dir.
32
+ // Override with PRETTY_FFF_DIR; otherwise ~/.cache/pi/fff
33
+ // ($XDG_CACHE_HOME/pi/fff when XDG_CACHE_HOME is set).
34
+ const override = process.env.PRETTY_FFF_DIR?.trim();
35
+ if (override) return override;
36
+ const home = process.env.HOME ?? "";
37
+ const cacheHome = process.env.XDG_CACHE_HOME?.trim() || join(home, ".cache");
38
+ return join(cacheHome, "pi", "fff");
39
+ }
40
+
41
+ export async function fffEnsureFinder(
42
+ cwd: string,
43
+ ): Promise<FffBackedFinder | null> {
44
+ if (fffState.finder && !fffState.finder.isDestroyed) return fffState.finder;
45
+ if (!fffState.module || !fffState.dbDir) return null;
46
+
47
+ const result = fffState.module.FileFinder.create({
48
+ basePath: cwd,
49
+ frecencyDbPath: join(fffState.dbDir, "frecency.mdb"),
50
+ historyDbPath: join(fffState.dbDir, "history.mdb"),
51
+ aiMode: true,
52
+ });
53
+
54
+ if (!result.ok) throw new Error(`FFF init failed: ${result.error}`);
55
+
56
+ fffState.finder = result.value;
57
+ const scan = await fffState.finder.waitForScan(FFF_SCAN_TIMEOUT);
58
+ fffState.partialIndex = scan.ok && !scan.value;
59
+
60
+ return fffState.finder;
61
+ }
62
+
63
+ export function fffDestroy(): void {
64
+ if (fffState.finder && !fffState.finder.isDestroyed) {
65
+ fffState.finder.destroy();
66
+ fffState.finder = null;
67
+ }
68
+ fffState.partialIndex = false;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // FFF helpers (CursorStore, grep formatting)
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function sanitizeGrepRecordContent(text: string): string {
76
+ let content = text;
77
+ if (content.endsWith("\r\n")) content = content.slice(0, -2);
78
+ else if (content.endsWith("\r") || content.endsWith("\n"))
79
+ content = content.slice(0, -1);
80
+
81
+ return content
82
+ .replace(/\r\n/g, "\\n")
83
+ .replace(/\r/g, "\\r")
84
+ .replace(/\n/g, "\\n");
85
+ }
86
+
87
+ function truncateGrepRecordContent(text: string): string {
88
+ const content = sanitizeGrepRecordContent(text);
89
+ return content.length > 500 ? `${content.slice(0, 500)}...` : content;
90
+ }
91
+
92
+ /**
93
+ * Store for FFF grep pagination cursors.
94
+ * Evicts oldest entry when exceeding maxSize.
95
+ */
96
+ export class CursorStore {
97
+ private cursors = new Map<string, GrepCursor>();
98
+ private counter = 0;
99
+ private maxSize: number;
100
+
101
+ constructor(maxSize = 200) {
102
+ this.maxSize = maxSize;
103
+ }
104
+
105
+ store(cursor: GrepCursor): string {
106
+ const id = `fff_c${++this.counter}`;
107
+ this.cursors.set(id, cursor);
108
+ if (this.cursors.size > this.maxSize) {
109
+ const first = this.cursors.keys().next().value;
110
+ if (first) this.cursors.delete(first);
111
+ }
112
+ return id;
113
+ }
114
+
115
+ get(id: string): GrepCursor | undefined {
116
+ return this.cursors.get(id);
117
+ }
118
+
119
+ get size(): number {
120
+ return this.cursors.size;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Convert FFF GrepResult items to ripgrep-style "file:line:content" text.
126
+ * This ensures pi-pretty's renderGrepResults works unchanged.
127
+ */
128
+ export function fffFormatGrepText(items: GrepMatch[], limit: number): string {
129
+ const capped = items.slice(0, limit);
130
+ if (!capped.length) return "No matches found";
131
+
132
+ const lines: string[] = [];
133
+ let currentFile = "";
134
+
135
+ for (const match of capped) {
136
+ if (match.relativePath !== currentFile) {
137
+ if (currentFile) lines.push("");
138
+ currentFile = match.relativePath;
139
+ }
140
+ if (match.contextBefore?.length) {
141
+ const startLine = match.lineNumber - match.contextBefore.length;
142
+ for (let i = 0; i < match.contextBefore.length; i++) {
143
+ lines.push(
144
+ `${match.relativePath}-${startLine + i}-${truncateGrepRecordContent(match.contextBefore[i] ?? "")}`,
145
+ );
146
+ }
147
+ }
148
+ lines.push(
149
+ `${match.relativePath}:${match.lineNumber}:${truncateGrepRecordContent(match.lineContent)}`,
150
+ );
151
+ if (match.contextAfter?.length) {
152
+ const startLine = match.lineNumber + 1;
153
+ for (let i = 0; i < match.contextAfter.length; i++) {
154
+ lines.push(
155
+ `${match.relativePath}-${startLine + i}-${truncateGrepRecordContent(match.contextAfter[i] ?? "")}`,
156
+ );
157
+ }
158
+ }
159
+ }
160
+
161
+ return lines.join("\n");
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Multi-grep ripgrep fallback
166
+ // ---------------------------------------------------------------------------
167
+
168
+ const GLOB_META_RE = /[*?[{]/;
169
+
170
+ function trimSlashes(value: string): string {
171
+ return value.replace(/^\/+/, "").replace(/\/+$/, "");
172
+ }
173
+
174
+ function normalizeConstraintPath(value: string): string {
175
+ let normalized = value.replace(/\\/g, "/").trim();
176
+ while (normalized.startsWith("./")) normalized = normalized.slice(2);
177
+ return normalized;
178
+ }
179
+
180
+ function tokenizeConstraints(constraints: string): ConstraintParseResult {
181
+ const tokens: string[] = [];
182
+ let current = "";
183
+ let quote: '"' | "'" | null = null;
184
+
185
+ for (let i = 0; i < constraints.length; i++) {
186
+ const char = constraints[i];
187
+ if (quote) {
188
+ if (char === quote) quote = null;
189
+ else current += char;
190
+ continue;
191
+ }
192
+
193
+ if (char === '"' || char === "'") {
194
+ quote = char;
195
+ continue;
196
+ }
197
+
198
+ if (/\s/.test(char)) {
199
+ if (current) {
200
+ tokens.push(current);
201
+ current = "";
202
+ }
203
+ continue;
204
+ }
205
+
206
+ current += char;
207
+ }
208
+
209
+ if (quote) return { ok: false, error: "unterminated quoted constraint" };
210
+ if (current) tokens.push(current);
211
+
212
+ return { ok: true, globs: [], tokens };
213
+ }
214
+
215
+ function tokenToRipgrepGlob(
216
+ token: string,
217
+ ): { ok: true; glob: string } | { ok: false; error: string } {
218
+ let negated = false;
219
+ let body = token;
220
+
221
+ if (body.startsWith("!")) {
222
+ negated = true;
223
+ body = body.slice(1);
224
+ }
225
+
226
+ body = normalizeConstraintPath(body);
227
+ if (!body) return { ok: false, error: `empty constraint token: ${token}` };
228
+ if (body.includes("\0"))
229
+ return {
230
+ ok: false,
231
+ error: `invalid NUL byte in constraint token: ${token}`,
232
+ };
233
+
234
+ let glob: string;
235
+ if (body.endsWith("/")) {
236
+ const dir = trimSlashes(body);
237
+ if (!dir)
238
+ return { ok: false, error: `empty directory constraint: ${token}` };
239
+ glob = `**/${dir}/**`;
240
+ } else if (GLOB_META_RE.test(body) || body.includes("/")) {
241
+ glob = body.replace(/^\/+/, "");
242
+ } else if (body.includes(".")) {
243
+ glob = `**/${body}`;
244
+ } else {
245
+ glob = `**/${body}/**`;
246
+ }
247
+
248
+ return { ok: true, glob: negated ? `!${glob}` : glob };
249
+ }
250
+
251
+ export function parseMultiGrepConstraints(
252
+ constraints: string | undefined,
253
+ ): ConstraintParseResult {
254
+ const trimmed = constraints?.trim();
255
+ if (!trimmed) return { ok: true, globs: [], tokens: [] };
256
+
257
+ const tokenized = tokenizeConstraints(trimmed);
258
+ if (!tokenized.ok) return tokenized;
259
+
260
+ const globs: string[] = [];
261
+ for (const token of tokenized.tokens) {
262
+ const parsed = tokenToRipgrepGlob(token);
263
+ if (!parsed.ok) return parsed;
264
+ globs.push(parsed.glob);
265
+ }
266
+
267
+ return { ok: true, globs, tokens: tokenized.tokens };
268
+ }
269
+
270
+ function isRipgrepMatchLine(line: string): boolean {
271
+ return /^.+?:\d+:/.test(line);
272
+ }
273
+
274
+ function buildRipgrepArgs(
275
+ params: MultiGrepRipgrepFallbackParams,
276
+ globs: string[],
277
+ ): string[] {
278
+ const args = [
279
+ "--line-number",
280
+ "--with-filename",
281
+ "--color=never",
282
+ "--hidden",
283
+ "--fixed-strings",
284
+ ];
285
+
286
+ if (params.ignoreCase) args.push("--ignore-case");
287
+ if (params.context && params.context > 0)
288
+ args.push("--context", String(params.context));
289
+
290
+ for (const glob of globs) args.push("--glob", glob);
291
+ for (const pattern of params.patterns) args.push("-e", pattern);
292
+
293
+ const searchPath = params.path?.trim();
294
+ if (searchPath) args.push("--", searchPath);
295
+
296
+ return args;
297
+ }
298
+
299
+ export function getMultiGrepRipgrepArgs(
300
+ params: MultiGrepRipgrepFallbackParams,
301
+ ): ConstraintParseResult & { args?: string[] } {
302
+ const parsed = parseMultiGrepConstraints(params.constraints);
303
+ if (!parsed.ok) return parsed;
304
+ return { ...parsed, args: buildRipgrepArgs(params, parsed.globs) };
305
+ }
306
+
307
+ export async function runMultiGrepRipgrepFallback(
308
+ params: MultiGrepRipgrepFallbackParams,
309
+ ): Promise<MultiGrepRipgrepFallbackResult> {
310
+ const parsed = parseMultiGrepConstraints(params.constraints);
311
+ if (!parsed.ok) throw new Error(`unsupported constraints: ${parsed.error}`);
312
+
313
+ const args = buildRipgrepArgs(params, parsed.globs);
314
+
315
+ return new Promise((resolve, reject) => {
316
+ if (params.signal?.aborted) {
317
+ reject(new Error("Operation aborted"));
318
+ return;
319
+ }
320
+
321
+ const child = spawn("rg", args, {
322
+ cwd: params.cwd,
323
+ stdio: ["ignore", "pipe", "pipe"],
324
+ });
325
+ const outputLines: string[] = [];
326
+ let stderr = "";
327
+ let buffer = "";
328
+ let matchCount = 0;
329
+ let limitReached = false;
330
+ let killedForLimit = false;
331
+ let settled = false;
332
+
333
+ const settle = (fn: () => void): void => {
334
+ if (settled) return;
335
+ settled = true;
336
+ fn();
337
+ };
338
+
339
+ const stopChild = (dueToLimit = false): void => {
340
+ if (!child.killed) {
341
+ killedForLimit = dueToLimit;
342
+ child.kill();
343
+ }
344
+ };
345
+
346
+ const onAbort = (): void => stopChild(false);
347
+ params.signal?.addEventListener("abort", onAbort, { once: true });
348
+
349
+ const cleanup = (): void => {
350
+ params.signal?.removeEventListener("abort", onAbort);
351
+ };
352
+
353
+ const handleLine = (line: string): void => {
354
+ if (limitReached) return;
355
+ outputLines.push(line);
356
+ if (isRipgrepMatchLine(line)) {
357
+ matchCount++;
358
+ if (matchCount >= params.limit) {
359
+ limitReached = true;
360
+ stopChild(true);
361
+ }
362
+ }
363
+ };
364
+
365
+ child.stdout?.on("data", (chunk: Buffer) => {
366
+ buffer += chunk.toString("utf8");
367
+ let newlineIndex = buffer.indexOf("\n");
368
+ while (newlineIndex >= 0) {
369
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
370
+ buffer = buffer.slice(newlineIndex + 1);
371
+ handleLine(line);
372
+ newlineIndex = buffer.indexOf("\n");
373
+ }
374
+ });
375
+
376
+ child.stderr?.on("data", (chunk: Buffer) => {
377
+ stderr += chunk.toString("utf8");
378
+ });
379
+
380
+ child.on("error", (error: NodeJS.ErrnoException) => {
381
+ cleanup();
382
+ const message =
383
+ error.code === "ENOENT"
384
+ ? "ripgrep (rg) is not available"
385
+ : `Failed to run ripgrep: ${error.message}`;
386
+ settle(() => reject(new Error(message)));
387
+ });
388
+
389
+ child.on("close", (code) => {
390
+ cleanup();
391
+
392
+ if (params.signal?.aborted) {
393
+ settle(() => reject(new Error("Operation aborted")));
394
+ return;
395
+ }
396
+
397
+ if (buffer && !limitReached) handleLine(buffer.replace(/\r$/, ""));
398
+
399
+ if (!killedForLimit && code !== 0 && code !== 1) {
400
+ const message = stderr.trim() || `ripgrep exited with code ${code}`;
401
+ settle(() => reject(new Error(message)));
402
+ return;
403
+ }
404
+
405
+ settle(() =>
406
+ resolve({
407
+ text: outputLines.length
408
+ ? outputLines.join("\n")
409
+ : "No matches found",
410
+ matchCount,
411
+ limitReached,
412
+ }),
413
+ );
414
+ });
415
+ });
416
+ }
@@ -0,0 +1,118 @@
1
+ import { normalizeShikiContrast } from "./ansi.js";
2
+ import { CACHE_LIMIT, MAX_HL_CHARS, THEME } from "./config.js";
3
+ import type { BundledLanguage } from "./types.js";
4
+
5
+ // Engine: cli-highlight (highlight.js-backed, synchronous ANSI output).
6
+ //
7
+ // cli-highlight colors via chalk, which decides its color level ONCE based on
8
+ // TTY/env detection. Shiki's codeToANSI always emitted truecolor regardless of
9
+ // stream; to match that (pi renders highlighted output into its own TUI, which
10
+ // is not the process stdout chalk inspects) we default FORCE_COLOR before chalk
11
+ // initializes, and lazy-load cli-highlight so this runs first. Respect an
12
+ // explicit FORCE_COLOR/NO_COLOR if the user set one.
13
+ if (
14
+ process.env.FORCE_COLOR === undefined &&
15
+ process.env.NO_COLOR === undefined
16
+ ) {
17
+ process.env.FORCE_COLOR = "3";
18
+ }
19
+
20
+ type CliHighlight = typeof import("cli-highlight");
21
+
22
+ let _hl: CliHighlight | null = null;
23
+
24
+ // Deterministically force chalk's color level to truecolor. The FORCE_COLOR
25
+ // env default above only works if chalk has not been required yet — but if
26
+ // ANY transitive dependency loads chalk before this module evaluates, chalk
27
+ // freezes its level at 0 (pi's TUI is not a TTY) and cli-highlight emits NO
28
+ // ANSI, so read/diff render as plain text. Setting chalk.level after require
29
+ // is load-order-independent and fixes that. Respect NO_COLOR.
30
+ function forceChalkColor(): void {
31
+ if (process.env.NO_COLOR !== undefined) return;
32
+ try {
33
+ const chalk = require("chalk");
34
+ const c = chalk?.default ?? chalk;
35
+ if (c && typeof c.level === "number" && c.level < 3) c.level = 3;
36
+ } catch {
37
+ /* chalk not resolvable — cli-highlight will fall back gracefully */
38
+ }
39
+ }
40
+
41
+ function cliHighlight(): CliHighlight | null {
42
+ if (_hl) return _hl;
43
+ try {
44
+ forceChalkColor();
45
+ _hl = require("cli-highlight") as CliHighlight;
46
+ } catch {
47
+ _hl = null;
48
+ }
49
+ return _hl;
50
+ }
51
+
52
+ const HLJS_LANG_ALIAS: Record<string, string> = {
53
+ tsx: "typescript",
54
+ jsx: "javascript",
55
+ jsonc: "json",
56
+ mdx: "markdown",
57
+ make: "makefile",
58
+ svelte: "html",
59
+ vue: "html",
60
+ };
61
+
62
+ function toHljsLang(language: BundledLanguage): string | undefined {
63
+ const hl = cliHighlight();
64
+ if (!hl) return undefined;
65
+ const mapped = HLJS_LANG_ALIAS[language] ?? language;
66
+ return hl.supportsLanguage(mapped) ? mapped : undefined;
67
+ }
68
+
69
+ export const _cache = new Map<string, string[]>();
70
+
71
+ function _touch(k: string, v: string[]): string[] {
72
+ _cache.delete(k);
73
+ _cache.set(k, v);
74
+ while (_cache.size > CACHE_LIMIT) {
75
+ const first = _cache.keys().next().value;
76
+ if (first === undefined) break;
77
+ _cache.delete(first);
78
+ }
79
+ return v;
80
+ }
81
+
82
+ // Async signature is preserved (renderers await hlBlock) even though
83
+ // cli-highlight is synchronous — keeps the call sites 1:1 with upstream.
84
+ export async function hlBlock(
85
+ code: string,
86
+ language: BundledLanguage | undefined,
87
+ ): Promise<string[]> {
88
+ if (!code) return [""];
89
+ if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
90
+
91
+ const hljsLang = toHljsLang(language);
92
+ if (!hljsLang) return code.split("\n");
93
+
94
+ const k = `${THEME}\0${hljsLang}\0${code}`;
95
+ const hit = _cache.get(k);
96
+ if (hit) return _touch(k, hit);
97
+
98
+ const hl = cliHighlight();
99
+ if (!hl) return code.split("\n");
100
+
101
+ try {
102
+ const ansi = normalizeShikiContrast(
103
+ hl.highlight(code, { language: hljsLang, ignoreIllegals: true }),
104
+ );
105
+ const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
106
+ return _touch(k, out);
107
+ } catch {
108
+ return code.split("\n");
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Renderers
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export function clearHighlightCache(): void {
117
+ _cache.clear();
118
+ }