@heyhuynhgiabuu/pi-pretty 0.5.3 → 0.6.1

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/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@heyhuynhgiabuu/pi-pretty",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Pretty terminal output for pi — syntax-highlighted file reads, colored bash output, tree-view directory listings, and more.",
5
5
  "author": "huynhgiabuu",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/buddingnewinsights/pi-pretty.git"
9
+ "url": "git+https://github.com/heyhuynhgiabuu/pi-pretty.git"
10
10
  },
11
- "homepage": "https://github.com/buddingnewinsights/pi-pretty#readme",
11
+ "homepage": "https://github.com/heyhuynhgiabuu/pi-pretty#readme",
12
12
  "bugs": {
13
- "url": "https://github.com/buddingnewinsights/pi-pretty/issues"
13
+ "url": "https://github.com/heyhuynhgiabuu/pi-pretty/issues"
14
14
  },
15
15
  "keywords": [
16
16
  "pi-package",
@@ -22,14 +22,12 @@
22
22
  "pretty-print"
23
23
  ],
24
24
  "dependencies": {
25
- "@ff-labs/fff-node": "0.5.2",
25
+ "@ff-labs/fff-node": "^0.9.4",
26
26
  "@shikijs/cli": "^4.0.2"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@earendil-works/pi-coding-agent": "*",
30
- "@earendil-works/pi-tui": "*",
31
- "@mariozechner/pi-coding-agent": "*",
32
- "@mariozechner/pi-tui": "*"
30
+ "@earendil-works/pi-tui": "*"
33
31
  },
34
32
  "devDependencies": {
35
33
  "@earendil-works/pi-coding-agent": "*",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * pi-pretty: FFF-backed @-mention autocomplete provider.
3
+ *
4
+ * Wraps the built-in autocomplete provider and replaces the @-mention file
5
+ * suggestions with FFF frecency-ranked file search results.
6
+ */
7
+
8
+ import type {
9
+ AutocompleteItem,
10
+ AutocompleteProvider,
11
+ AutocompleteSuggestions,
12
+ } from "@earendil-works/pi-tui";
13
+ import type { FileFinder } from "@ff-labs/fff-node";
14
+
15
+ /** How many @-mention suggestions to show at most. */
16
+ const MAX_SUGGESTIONS = 20;
17
+
18
+ /**
19
+ * Extract the query after a `@` character before the cursor.
20
+ * Returns the query string (empty string if bare `@`), or undefined if no `@` trigger.
21
+ */
22
+ function extractAtPrefix(textBeforeCursor: string): string | undefined {
23
+ const match = textBeforeCursor.match(/(?:^|[ \t])@([^\s@]*)$/);
24
+ return match?.[1];
25
+ }
26
+
27
+ export function createFffAutocompleteProvider(
28
+ current: AutocompleteProvider,
29
+ getFinder: () => FileFinder | null,
30
+ ): AutocompleteProvider {
31
+ return {
32
+ async getSuggestions(
33
+ lines: string[],
34
+ cursorLine: number,
35
+ cursorCol: number,
36
+ options: { signal: AbortSignal; force?: boolean },
37
+ ): Promise<AutocompleteSuggestions | null> {
38
+ const currentLine = lines[cursorLine] ?? "";
39
+ const textBeforeCursor = currentLine.slice(0, cursorCol);
40
+ const query = extractAtPrefix(textBeforeCursor);
41
+
42
+ // Not an @-mention — delegate
43
+ if (query === undefined) {
44
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
45
+ }
46
+
47
+ const finder = getFinder();
48
+ if (!finder) {
49
+ // FFF not available — delegate to built-in
50
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
51
+ }
52
+
53
+ try {
54
+ const result = finder.fileSearch(query, {
55
+ pageSize: MAX_SUGGESTIONS,
56
+
57
+ });
58
+
59
+ if (result.ok) {
60
+ const items: AutocompleteItem[] = result.value.items.map((item) => ({
61
+ value: item.relativePath,
62
+ label: item.fileName,
63
+ description: item.relativePath.slice(0, -(item.fileName.length + 1)),
64
+ }));
65
+
66
+ if (items.length > 0) {
67
+ return { items, prefix: `@${query}` };
68
+ }
69
+ }
70
+ } catch {
71
+ // FFF search failed — fall through to built-in
72
+ }
73
+
74
+ // FFF returned no results or failed — let built-in handle it
75
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
76
+ },
77
+
78
+ applyCompletion(
79
+ lines: string[],
80
+ cursorLine: number,
81
+ cursorCol: number,
82
+ item: AutocompleteItem,
83
+ prefix: string,
84
+ ): { lines: string[]; cursorLine: number; cursorCol: number } {
85
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
86
+ },
87
+
88
+ shouldTriggerFileCompletion(
89
+ lines: string[],
90
+ cursorLine: number,
91
+ cursorCol: number,
92
+ ): boolean {
93
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
94
+ },
95
+ };
96
+ }
package/src/config.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * pi-pretty: ANSI codes, icons, theme, and environment config.
3
+ */
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { basename, extname, join } from "node:path";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // ANSI
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export let RST = "\x1b[0m";
13
+ const BOLD = "\x1b[1m";
14
+
15
+ export const FG_LNUM = "\x1b[38;2;100;100;100m";
16
+ export const FG_DIM = "\x1b[38;2;80;80;80m";
17
+ export const FG_RULE = "\x1b[38;2;50;50;50m";
18
+ export const FG_GREEN = "\x1b[38;2;100;180;120m";
19
+ export const FG_RED = "\x1b[38;2;200;100;100m";
20
+ export const FG_YELLOW = "\x1b[38;2;220;180;80m";
21
+ export const FG_BLUE = "\x1b[38;2;100;140;220m";
22
+ export const FG_MUTED = "\x1b[38;2;139;148;158m";
23
+
24
+ const BG_DEFAULT = "\x1b[49m";
25
+ export let BG_BASE = BG_DEFAULT;
26
+ export let BG_ERROR = BG_DEFAULT;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Theme
30
+ // ---------------------------------------------------------------------------
31
+
32
+ import type { ThemeLike } from "./types.js";
33
+
34
+ type BgThemeLike = { getBgAnsi?: (key: string) => string };
35
+
36
+ const ESC_RE = "\u001b";
37
+
38
+ function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
39
+ const m = ansi.match(new RegExp(`${ESC_RE}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
40
+ return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
41
+ }
42
+
43
+ function getThemeBgAnsi(theme: BgThemeLike, key: string): string | null {
44
+ try {
45
+ const bgAnsi = theme.getBgAnsi?.(key);
46
+ return bgAnsi && parseAnsiRgb(bgAnsi) ? bgAnsi : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function hexToAnsiBg(hex: string): string | null {
53
+ const m = hex.match(/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
54
+ if (!m) return null;
55
+ const r = Number.parseInt(m[1], 16);
56
+ const g = Number.parseInt(m[2], 16);
57
+ const b = Number.parseInt(m[3], 16);
58
+ return `\x1b[48;2;${r};${g};${b}m`;
59
+ }
60
+
61
+ interface PrettyConfig {
62
+ background?: {
63
+ tool?: string;
64
+ error?: string;
65
+ };
66
+ }
67
+
68
+ function readPrettyConfig(agentDir?: string): PrettyConfig {
69
+ if (!agentDir) return {};
70
+ try {
71
+ const raw = readFileSync(join(agentDir, "pi-pretty.json"), "utf8");
72
+ const parsed = JSON.parse(raw) as PrettyConfig;
73
+ if (parsed.background) {
74
+ if (parsed.background.tool && !hexToAnsiBg(parsed.background.tool)) {
75
+ parsed.background.tool = undefined;
76
+ }
77
+ if (parsed.background.error && !hexToAnsiBg(parsed.background.error)) {
78
+ parsed.background.error = undefined;
79
+ }
80
+ if (!parsed.background.tool && !parsed.background.error) {
81
+ parsed.background = undefined;
82
+ }
83
+ }
84
+ return parsed;
85
+ } catch {
86
+ return {};
87
+ }
88
+ }
89
+
90
+ function applyPrettyConfigBg(agentDir?: string): boolean {
91
+ const config = readPrettyConfig(agentDir);
92
+ if (!config.background?.tool) return false;
93
+ const toolBg = hexToAnsiBg(config.background.tool);
94
+ if (!toolBg) return false;
95
+ BG_BASE = toolBg;
96
+ BG_ERROR = config.background.error ? (hexToAnsiBg(config.background.error) ?? toolBg) : toolBg;
97
+ RST = "\x1b[0m";
98
+ return true;
99
+ }
100
+
101
+ export function resolveBaseBackground(theme: BgThemeLike | null | undefined): void {
102
+ const home = process.env.HOME;
103
+ const configDir = process.env.PRETTY_CONFIG_DIR ?? (home ? join(home, ".pi/agent") : undefined);
104
+ if (applyPrettyConfigBg(configDir)) return;
105
+ if (!theme?.getBgAnsi) return;
106
+ BG_BASE = getThemeBgAnsi(theme, "toolSuccessBg") ?? getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
107
+ BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
108
+ RST = "\x1b[0m";
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Terminal
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export function termWidth(): number {
116
+ if (process.stdout.columns) return Math.max(1, Math.min(process.stdout.columns, 210));
117
+ const raw = (process.stderr as NodeJS.WriteStream & { columns?: number }).columns ||
118
+ Number.parseInt(process.env.COLUMNS ?? "", 10) || 200;
119
+ return Math.max(1, Math.min(raw - 4, 210));
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // File-type icons — Nerd Font glyphs
124
+ // ---------------------------------------------------------------------------
125
+
126
+ const ICONS_MODE = (process.env.PRETTY_ICONS ?? "nerd").toLowerCase();
127
+ export const USE_ICONS = ICONS_MODE !== "none" && ICONS_MODE !== "off";
128
+
129
+ export const NF_DIR = `${FG_BLUE}\ue5ff${RST}`;
130
+ export const NF_DEFAULT = `${FG_DIM}\uf15b${RST}`;
131
+
132
+ const EXT_ICON: Record<string, string> = {
133
+ ts: `\x1b[38;2;49;120;198m\ue628${RST}`,
134
+ tsx: `\x1b[38;2;49;120;198m\ue7ba${RST}`,
135
+ js: `\x1b[38;2;241;224;90m\ue74e${RST}`,
136
+ jsx: `\x1b[38;2;97;218;251m\ue7ba${RST}`,
137
+ mjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
138
+ cjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
139
+ py: `\x1b[38;2;55;118;171m\ue73c${RST}`,
140
+ rs: `\x1b[38;2;222;165;132m\ue7a8${RST}`,
141
+ go: `\x1b[38;2;0;173;216m\ue724${RST}`,
142
+ java: `\x1b[38;2;204;62;68m\ue738${RST}`,
143
+ swift: `\x1b[38;2;255;172;77m\ue755${RST}`,
144
+ rb: `\x1b[38;2;204;52;45m\ue739${RST}`,
145
+ kt: `\x1b[38;2;126;103;200m\ue634${RST}`,
146
+ c: `\x1b[38;2;85;154;211m\ue61e${RST}`,
147
+ cpp: `\x1b[38;2;85;154;211m\ue61d${RST}`,
148
+ cs: `\x1b[38;2;104;33;122m\ue648${RST}`,
149
+ html: `\x1b[38;2;228;77;38m\ue736${RST}`,
150
+ css: `\x1b[38;2;66;165;245m\ue749${RST}`,
151
+ scss: `\x1b[38;2;207;100;154m\ue749${RST}`,
152
+ vue: `\x1b[38;2;65;184;131m\ue6a0${RST}`,
153
+ svelte: `\x1b[38;2;255;62;0m\ue697${RST}`,
154
+ json: `\x1b[38;2;241;224;90m\ue60b${RST}`,
155
+ yaml: `\x1b[38;2;160;116;196m\ue6a8${RST}`,
156
+ yml: `\x1b[38;2;160;116;196m\ue6a8${RST}`,
157
+ toml: `\x1b[38;2;160;116;196m\ue6b2${RST}`,
158
+ xml: `\x1b[38;2;228;77;38m\ue619${RST}`,
159
+ md: `\x1b[38;2;66;165;245m\ue73e${RST}`,
160
+ mdx: `\x1b[38;2;66;165;245m\ue73e${RST}`,
161
+ sql: `\x1b[38;2;218;218;218m\ue706${RST}`,
162
+ sh: `\x1b[38;2;137;180;130m\ue795${RST}`,
163
+ bash: `\x1b[38;2;137;180;130m\ue795${RST}`,
164
+ zsh: `\x1b[38;2;137;180;130m\ue795${RST}`,
165
+ lua: `\x1b[38;2;81;160;207m\ue620${RST}`,
166
+ php: `\x1b[38;2;137;147;186m\ue73d${RST}`,
167
+ dart: `\x1b[38;2;87;182;240m\ue798${RST}`,
168
+ png: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
169
+ jpg: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
170
+ svg: `\x1b[38;2;255;180;50m\uf1c5${RST}`,
171
+ webp: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
172
+ lock: `\x1b[38;2;130;130;130m\uf023${RST}`,
173
+ env: `\x1b[38;2;241;224;90m\ue615${RST}`,
174
+ graphql: `\x1b[38;2;224;51;144m\ue662${RST}`,
175
+ dockerfile: `\x1b[38;2;56;152;236m\ue7b0${RST}`,
176
+ };
177
+
178
+ const NAME_ICON: Record<string, string> = {
179
+ "package.json": `\x1b[38;2;137;180;130m\ue71e${RST}`,
180
+ "package-lock.json": `\x1b[38;2;130;130;130m\ue71e${RST}`,
181
+ "tsconfig.json": `\x1b[38;2;49;120;198m\ue628${RST}`,
182
+ ".gitignore": `\x1b[38;2;222;165;132m\ue702${RST}`,
183
+ ".env": `\x1b[38;2;241;224;90m\ue615${RST}`,
184
+ dockerfile: `\x1b[38;2;56;152;236m\ue7b0${RST}`,
185
+ makefile: `\x1b[38;2;130;130;130m\ue615${RST}`,
186
+ "readme.md": `\x1b[38;2;66;165;245m\ue73e${RST}`,
187
+ license: `\x1b[38;2;218;218;218m\ue60a${RST}`,
188
+ };
189
+
190
+ export function fileIcon(fp: string): string {
191
+ if (!USE_ICONS) return "";
192
+ const base = basename(fp).toLowerCase();
193
+ if (NAME_ICON[base]) return `${NAME_ICON[base]} `;
194
+ const ext = extname(fp).slice(1).toLowerCase();
195
+ return EXT_ICON[ext] ? `${EXT_ICON[ext]} ` : `${NF_DEFAULT} `;
196
+ }
197
+
198
+ export function dirIcon(): string {
199
+ return USE_ICONS ? `${NF_DIR} ` : "";
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Language detection
204
+ // ---------------------------------------------------------------------------
205
+
206
+ import type { BundledLanguage } from "shiki";
207
+
208
+ const EXT_LANG: Record<string, BundledLanguage> = {
209
+ ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
210
+ mjs: "javascript", cjs: "javascript",
211
+ py: "python", rb: "ruby", rs: "rust", go: "go", java: "java",
212
+ c: "c", cpp: "cpp", h: "c", hpp: "cpp",
213
+ cs: "csharp", swift: "swift", kt: "kotlin",
214
+ html: "html", css: "css", scss: "scss", less: "css",
215
+ json: "json", jsonc: "jsonc", yaml: "yaml", yml: "yaml",
216
+ toml: "toml", md: "markdown", mdx: "mdx", sql: "sql",
217
+ sh: "bash", bash: "bash", zsh: "bash", lua: "lua", php: "php",
218
+ dart: "dart", xml: "xml", graphql: "graphql", svelte: "svelte", vue: "vue",
219
+ dockerfile: "dockerfile", makefile: "make",
220
+ zig: "zig", nim: "nim", elixir: "elixir",
221
+ };
222
+
223
+ export function detectLang(fp: string): BundledLanguage | undefined {
224
+ const base = basename(fp).toLowerCase();
225
+ if (base === "dockerfile") return "dockerfile";
226
+ if (base === "makefile" || base === "gnumakefile") return "make";
227
+ return EXT_LANG[extname(fp).slice(1).toLowerCase()];
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Env helpers
232
+ // ---------------------------------------------------------------------------
233
+
234
+ export function envInt(name: string, fallback: number): number {
235
+ const v = Number.parseInt(process.env[name] ?? "", 10);
236
+ return Number.isFinite(v) && v > 0 ? v : fallback;
237
+ }
238
+
239
+ export const MAX_HL_CHARS = envInt("PRETTY_MAX_HL_CHARS", 80_000);
240
+ export const MAX_PREVIEW_LINES = envInt("PRETTY_MAX_PREVIEW_LINES", 80);
241
+ export const CACHE_LIMIT = envInt("PRETTY_CACHE_LIMIT", 128);
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Agent directory helpers
245
+ // ---------------------------------------------------------------------------
246
+
247
+ export function getDefaultAgentDir(): string | undefined {
248
+ const home = process.env.HOME ?? "";
249
+ return home ? join(home, ".pi/agent") : undefined;
250
+ }
package/src/fff.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * pi-pretty: FFF lifecycle management.
3
+ *
4
+ * Manages the FFF file finder lifecycle: create, wait for scan, destroy.
5
+ * Tools check `isAvailable` and use `finder` directly for search operations.
6
+ * Graceful fallback: if FFF is not installed or fails, tools degrade to SDK.
7
+ */
8
+
9
+ import { mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import type { FileFinder as FffFileFinder, GrepCursor } from "@ff-labs/fff-node";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Cursor store — pagination cursors for grep
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export class CursorStore {
19
+ private cursors = new Map<string, GrepCursor>();
20
+ private counter = 0;
21
+ private maxSize: number;
22
+
23
+ constructor(maxSize = 200) {
24
+ this.maxSize = maxSize;
25
+ }
26
+
27
+ store(cursor: GrepCursor): string {
28
+ const id = `fff_c${++this.counter}`;
29
+ this.cursors.set(id, cursor);
30
+ if (this.cursors.size > this.maxSize) {
31
+ const first = this.cursors.keys().next().value;
32
+ if (first) this.cursors.delete(first);
33
+ }
34
+ return id;
35
+ }
36
+
37
+ get(id: string): GrepCursor | undefined {
38
+ return this.cursors.get(id);
39
+ }
40
+
41
+ get size(): number {
42
+ return this.cursors.size;
43
+ }
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // FffService — wraps a single FileFinder instance
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export class FffService {
51
+ finder: FffFileFinder | null = null;
52
+ partialIndex = false;
53
+ cursorStore = new CursorStore();
54
+
55
+ private fffModule: typeof import("@ff-labs/fff-node") | null = null;
56
+ private dbDir: string | null = null;
57
+ private finderPromise: Promise<void> | null = null;
58
+
59
+ constructor(fffModule?: typeof import("@ff-labs/fff-node"), agentDir?: string) {
60
+ if (fffModule) {
61
+ this.fffModule = fffModule;
62
+ if (agentDir) {
63
+ this.dbDir = join(agentDir, "pi-pretty", "fff");
64
+ try {
65
+ mkdirSync(this.dbDir, { recursive: true });
66
+ } catch { /* ignore */ }
67
+ }
68
+ }
69
+ }
70
+
71
+ /** Returns true if the FFF native module is loaded (installed). */
72
+ isModuleLoaded(): boolean {
73
+ return this.fffModule !== null;
74
+ }
75
+
76
+ get isAvailable(): boolean {
77
+ return this.finder !== null && !this.finder.isDestroyed;
78
+ }
79
+
80
+ /** Attempt to load FFF module dynamically (called during session_start if not injected). */
81
+ async tryLoadModule(): Promise<boolean> {
82
+ if (this.fffModule) return true;
83
+ try {
84
+ const mod = await import("@ff-labs/fff-node");
85
+ this.fffModule = mod;
86
+ if (!this.dbDir && process.env.HOME) {
87
+ this.dbDir = join(process.env.HOME, ".pi/agent", "pi-pretty", "fff");
88
+ try {
89
+ mkdirSync(this.dbDir, { recursive: true });
90
+ } catch { /* ignore */ }
91
+ }
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ async ensureFinder(cwd: string): Promise<void> {
99
+ if (this.finder && !this.finder.isDestroyed) return;
100
+ if (this.finderPromise) return this.finderPromise;
101
+
102
+ this.finderPromise = this._createFinder(cwd);
103
+ await this.finderPromise;
104
+ this.finderPromise = null;
105
+ }
106
+
107
+ private async _createFinder(cwd: string): Promise<void> {
108
+ if (!this.fffModule) return;
109
+
110
+ if (this.finder && !this.finder.isDestroyed) {
111
+ this.finder.destroy();
112
+ this.finder = null;
113
+ }
114
+
115
+ const result = this.fffModule.FileFinder.create({
116
+ basePath: cwd,
117
+ frecencyDbPath: this.dbDir ? join(this.dbDir, "frecency.mdb") : "",
118
+ historyDbPath: this.dbDir ? join(this.dbDir, "history.mdb") : "",
119
+ aiMode: true,
120
+ });
121
+
122
+ if (!result.ok) {
123
+ throw new Error(`FFF init failed: ${result.error}`);
124
+ }
125
+
126
+ this.finder = result.value;
127
+ const scan = await this.finder.waitForScan(15_000);
128
+ this.partialIndex = scan.ok && !scan.value;
129
+ }
130
+
131
+ destroy(): void {
132
+ if (this.finder && !this.finder.isDestroyed) {
133
+ this.finder.destroy();
134
+ this.finder = null;
135
+ }
136
+ this.partialIndex = false;
137
+ this.finderPromise = null;
138
+ }
139
+
140
+ getFinder(): import("@ff-labs/fff-node").FileFinder | null {
141
+ return this.finder;
142
+ }
143
+
144
+ getCursorStore(): CursorStore {
145
+ return this.cursorStore;
146
+ }
147
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * pi-pretty: utility helpers.
3
+ */
4
+
5
+ import { relative } from "node:path";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // String / normalization
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export function normalizeLineEndings(text: string): string {
12
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
13
+ }
14
+
15
+ export function shortPath(cwd: string, home: string, p: string): string {
16
+ if (!p) return "";
17
+ const r = relative(cwd, p);
18
+ if (!r.startsWith("..") && !r.startsWith("/")) return r;
19
+ return p.replace(home, "~");
20
+ }
21
+
22
+ export function trimToUndefined(value: string | undefined): string | undefined {
23
+ const trimmed = value?.trim();
24
+ return trimmed ? trimmed : undefined;
25
+ }
26
+
27
+ export function escapeRegexLiteral(text: string): string {
28
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+ }
30
+
31
+ export function buildLiteralAlternationPattern(patterns: string[]): string {
32
+ return patterns
33
+ .map(escapeRegexLiteral)
34
+ .sort((a, b) => b.length - a.length)
35
+ .join("|");
36
+ }
37
+
38
+ export function shouldIgnoreCaseForPatterns(patterns: string[]): boolean {
39
+ return patterns.every((pattern) => pattern.toLowerCase() === pattern);
40
+ }
41
+
42
+ export function getConstraintBackedPath(constraints: string | undefined): string | undefined {
43
+ const trimmed = trimToUndefined(constraints);
44
+ if (!trimmed || /\s/.test(trimmed) || trimmed.includes("!") || trimmed.endsWith("/") || /[*?[{]/.test(trimmed)) {
45
+ return undefined;
46
+ }
47
+ return trimmed;
48
+ }
49
+
50
+ export function getErrorMessage(error: unknown): string {
51
+ return error instanceof Error ? error.message : String(error);
52
+ }
53
+
54
+ export function humanSize(bytes: number): string {
55
+ if (bytes < 1024) return `${bytes}B`;
56
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
57
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Ripgrep match detection
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export function countRipgrepMatches(text: string): number {
65
+ return text
66
+ .trim()
67
+ .split("\n")
68
+ .filter((line) => /^.+?[:-]\d+[:-]/.test(line)).length;
69
+ }
70
+
71
+ export function stripBashExitStatusLine(text: string): string {
72
+ return normalizeLineEndings(text)
73
+ .split("\n")
74
+ .filter((line) => !/^Command exited with code \d+$/i.test(line.trim()))
75
+ .join("\n");
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Tool metrics
80
+ // ---------------------------------------------------------------------------
81
+
82
+ export function formatElapsedMs(ms: number | undefined): string {
83
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return "";
84
+ if (ms < 1000) return `${Math.round(ms)}ms`;
85
+ const s = ms / 1000;
86
+ return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`;
87
+ }
88
+
89
+ export function formatCharCount(chars: number | undefined): string {
90
+ if (typeof chars !== "number" || !Number.isFinite(chars) || chars <= 0) return "";
91
+ if (chars < 1000) return `${chars} chars`;
92
+ if (chars < 10_000) return `${(chars / 1000).toFixed(1)}k chars`;
93
+ return `${Math.round(chars / 1000)}k chars`;
94
+ }
95
+
96
+ export const ELAPSED_KEY = "__prettyElapsedMs";
97
+ export const CHARS_KEY = "__prettyOutputChars";
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Infer bash exit code
101
+ // ---------------------------------------------------------------------------
102
+
103
+ export function inferBashExitCode(text: string, fallback: number | null): number | null {
104
+ const exitMatch = text.match(/(?:exit code|exited with(?: code)?|exit status)[:\s]*(\d+)/i);
105
+ if (exitMatch) return Number(exitMatch[1]);
106
+ if (text.includes("command not found") || text.includes("No such file")) return 1;
107
+ return fallback;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Compact error lines
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export function compactErrorLines(error: string): string[] {
115
+ const compactedLines: string[] = [];
116
+ let previousBlank = false;
117
+ for (const line of normalizeLineEndings(error).trim().split("\n")) {
118
+ const isBlank = line.trim() === "";
119
+ if (isBlank && previousBlank) continue;
120
+ compactedLines.push(line);
121
+ previousBlank = isBlank;
122
+ }
123
+ return compactedLines;
124
+ }