@heyhuynhgiabuu/pi-pretty 0.5.3 → 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/bun.lock +598 -0
- package/package.json +6 -8
- 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 -2161
- 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 +3 -3
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": "*",
|
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
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-pretty: Terminal image rendering (iTerm2 / Kitty / Ghostty inline image protocols).
|
|
3
|
+
* Handles tmux passthrough for image protocols.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
type ImageProtocol = "iterm2" | "kitty" | "none";
|
|
9
|
+
|
|
10
|
+
let _tmuxClientTermCache: string | null | undefined;
|
|
11
|
+
let _tmuxAllowPassthroughCache: boolean | null | undefined;
|
|
12
|
+
let _tmuxClientTermOverrideForTests: string | null | undefined;
|
|
13
|
+
let _tmuxAllowPassthroughOverrideForTests: boolean | null | undefined;
|
|
14
|
+
|
|
15
|
+
function isTmuxSession(): boolean {
|
|
16
|
+
return !!process.env.TMUX || /^(tmux|screen)/.test(process.env.TERM ?? "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeTerminalName(term: string): string {
|
|
20
|
+
const t = term.toLowerCase();
|
|
21
|
+
if (t.includes("kitty")) return "kitty";
|
|
22
|
+
if (t.includes("ghostty")) return "ghostty";
|
|
23
|
+
if (t.includes("wezterm")) return "WezTerm";
|
|
24
|
+
if (t.includes("iterm")) return "iTerm.app";
|
|
25
|
+
if (t.includes("mintty")) return "mintty";
|
|
26
|
+
return term;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readTmuxClientTerm(): string | null {
|
|
30
|
+
if (_tmuxClientTermOverrideForTests !== undefined) {
|
|
31
|
+
return _tmuxClientTermOverrideForTests ? normalizeTerminalName(_tmuxClientTermOverrideForTests) : null;
|
|
32
|
+
}
|
|
33
|
+
if (!isTmuxSession()) return null;
|
|
34
|
+
if (_tmuxClientTermCache !== undefined) return _tmuxClientTermCache;
|
|
35
|
+
try {
|
|
36
|
+
const term = execFileSync("tmux", ["display-message", "-p", "#{client_termname}"], {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
39
|
+
timeout: 200,
|
|
40
|
+
}).trim();
|
|
41
|
+
_tmuxClientTermCache = term ? normalizeTerminalName(term) : null;
|
|
42
|
+
} catch {
|
|
43
|
+
_tmuxClientTermCache = null;
|
|
44
|
+
}
|
|
45
|
+
return _tmuxClientTermCache;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getOuterTerminal(): string {
|
|
49
|
+
if (process.env.LC_TERMINAL === "iTerm2") return "iTerm.app";
|
|
50
|
+
if (process.env.GHOSTTY_RESOURCES_DIR) return "ghostty";
|
|
51
|
+
if (process.env.KITTY_WINDOW_ID || process.env.KITTY_PID) return "kitty";
|
|
52
|
+
if (process.env.WEZTERM_EXECUTABLE || process.env.WEZTERM_CONFIG_DIR || process.env.WEZTERM_CONFIG_FILE) {
|
|
53
|
+
return "WezTerm";
|
|
54
|
+
}
|
|
55
|
+
const termProgram = process.env.TERM_PROGRAM ?? "";
|
|
56
|
+
if (termProgram && termProgram !== "tmux" && termProgram !== "screen") {
|
|
57
|
+
return normalizeTerminalName(termProgram);
|
|
58
|
+
}
|
|
59
|
+
const tmuxClientTerm = readTmuxClientTerm();
|
|
60
|
+
if (tmuxClientTerm) return tmuxClientTerm;
|
|
61
|
+
const term = process.env.TERM ?? "";
|
|
62
|
+
if (term) return normalizeTerminalName(term);
|
|
63
|
+
if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit") return "unknown-modern";
|
|
64
|
+
return termProgram;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function detectImageProtocol(): ImageProtocol {
|
|
68
|
+
const forced = (process.env.PRETTY_IMAGE_PROTOCOL ?? "").toLowerCase();
|
|
69
|
+
if (forced === "kitty" || forced === "iterm2" || forced === "none") return forced;
|
|
70
|
+
const term = getOuterTerminal();
|
|
71
|
+
if (term === "ghostty" || term === "kitty") return "kitty";
|
|
72
|
+
if (["iTerm.app", "WezTerm", "mintty"].includes(term)) return "iterm2";
|
|
73
|
+
if (process.env.LC_TERMINAL === "iTerm2") return "iterm2";
|
|
74
|
+
return "none";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function tmuxAllowsPassthrough(): boolean | null {
|
|
78
|
+
if (_tmuxAllowPassthroughOverrideForTests !== undefined) return _tmuxAllowPassthroughOverrideForTests;
|
|
79
|
+
if (!isTmuxSession()) return null;
|
|
80
|
+
if (_tmuxAllowPassthroughCache !== undefined) return _tmuxAllowPassthroughCache;
|
|
81
|
+
try {
|
|
82
|
+
const value = execFileSync("tmux", ["show-options", "-gv", "allow-passthrough"], {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
85
|
+
timeout: 200,
|
|
86
|
+
})
|
|
87
|
+
.trim()
|
|
88
|
+
.toLowerCase();
|
|
89
|
+
_tmuxAllowPassthroughCache = value === "on" || value === "all";
|
|
90
|
+
} catch {
|
|
91
|
+
_tmuxAllowPassthroughCache = null;
|
|
92
|
+
}
|
|
93
|
+
return _tmuxAllowPassthroughCache;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getTmuxPassthroughWarning(protocol: ImageProtocol): string | null {
|
|
97
|
+
if (!isTmuxSession() || protocol === "none") return null;
|
|
98
|
+
if (tmuxAllowsPassthrough() === false) {
|
|
99
|
+
return 'tmux allow-passthrough is off. Run: tmux set -g allow-passthrough on';
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tmuxWrap(seq: string): string {
|
|
105
|
+
if (!isTmuxSession()) return seq;
|
|
106
|
+
const escaped = seq.split("\x1b").join("\x1b\x1b");
|
|
107
|
+
return `\x1bPtmux;${escaped}\x1b\\`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const __imageInternals = {
|
|
111
|
+
isTmuxSession,
|
|
112
|
+
getOuterTerminal,
|
|
113
|
+
detectImageProtocol,
|
|
114
|
+
tmuxWrap,
|
|
115
|
+
tmuxAllowsPassthrough,
|
|
116
|
+
getTmuxPassthroughWarning,
|
|
117
|
+
setTmuxClientTermOverrideForTests: (value: string | null | undefined) => {
|
|
118
|
+
_tmuxClientTermOverrideForTests = value;
|
|
119
|
+
},
|
|
120
|
+
setTmuxAllowPassthroughOverrideForTests: (value: boolean | null | undefined) => {
|
|
121
|
+
_tmuxAllowPassthroughOverrideForTests = value;
|
|
122
|
+
},
|
|
123
|
+
resetCachesForTests: () => {
|
|
124
|
+
_tmuxClientTermCache = undefined;
|
|
125
|
+
_tmuxAllowPassthroughCache = undefined;
|
|
126
|
+
_tmuxClientTermOverrideForTests = undefined;
|
|
127
|
+
_tmuxAllowPassthroughOverrideForTests = undefined;
|
|
128
|
+
},
|
|
129
|
+
};
|