@heyhuynhgiabuu/pi-pretty 0.5.2 → 0.6.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 +21 -0
- package/bun.lock +598 -0
- package/package.json +6 -8
- package/pi-pretty.example.json +6 -0
- package/release-notes/v0.5.3.md +29 -0
- package/src/config.ts +250 -0
- package/src/fff.ts +147 -0
- package/src/helpers.ts +124 -0
- package/src/image.ts +129 -0
- package/src/index.ts +157 -1980
- package/src/render.ts +402 -0
- package/src/tools/bash.ts +115 -0
- package/src/tools/find.ts +87 -0
- package/src/tools/grep.ts +99 -0
- package/src/tools/ls.ts +66 -0
- package/src/tools/metrics.ts +40 -0
- package/src/tools/multi-grep.ts +171 -0
- package/src/tools/read.ts +112 -0
- package/src/types.ts +227 -0
- package/test/bash-rendering.test.ts +104 -1
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyhuynhgiabuu/pi-pretty",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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/
|
|
9
|
+
"url": "git+https://github.com/heyhuynhgiabuu/pi-pretty.git"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://github.com/
|
|
11
|
+
"homepage": "https://github.com/heyhuynhgiabuu/pi-pretty#readme",
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/
|
|
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.
|
|
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,29 @@
|
|
|
1
|
+
# pi-pretty 0.5.3
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
This release adds user-configurable tool output backgrounds via a `pi-pretty.json` config file, replacing the previous hardcoded theme-only dependency. It also fixes error tool background misalignment where the padding area showed the wrong background color.
|
|
5
|
+
|
|
6
|
+
## What changed
|
|
7
|
+
- Add `pi-pretty.json` config file support (loaded from `~/.pi/agent/pi-pretty.json` alongside `settings.json`).
|
|
8
|
+
- Config fields: `background.tool` (normal tool output bg, hex) and `background.error` (error tool output bg, hex, defaults to tool bg).
|
|
9
|
+
- Config values take priority over theme-provided backgrounds (`toolBg` / `toolErrorBg`).
|
|
10
|
+
- Add `PRETTY_CONFIG_DIR` env var to override the config directory.
|
|
11
|
+
- Fix: `fillToolBackground()` now uses a per-line RST matching the line's background color, fixing a visible color seam where padding showed `BG_BASE` instead of `BG_ERROR` on error tool output.
|
|
12
|
+
- Fix: `renderToolError()` restores top spacing and applies two-space left padding to every line of multi-line error output.
|
|
13
|
+
- Fix: `multi_grep` error path now uses `renderToolError()` instead of raw text without background fill.
|
|
14
|
+
- Add `hexToAnsiBg()` converter and `PrettyConfig` exported interface.
|
|
15
|
+
- Add `pi-pretty.example.json` in the project root.
|
|
16
|
+
- Update README with configuration documentation.
|
|
17
|
+
|
|
18
|
+
## Files
|
|
19
|
+
- `src/index.ts`
|
|
20
|
+
- `pi-pretty.example.json`
|
|
21
|
+
- `README.md`
|
|
22
|
+
|
|
23
|
+
## Verification
|
|
24
|
+
- `npm run typecheck` ✅
|
|
25
|
+
- `npm run lint` ✅ (no new issues)
|
|
26
|
+
- `npm test` ✅ (74 tests)
|
|
27
|
+
|
|
28
|
+
## Upgrade notes
|
|
29
|
+
No configuration changes are required. Existing behavior is unchanged — config is optional. To customize backgrounds, create `~/.pi/agent/pi-pretty.json` with the `background` fields.
|
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
|
+
}
|