@heyhuynhgiabuu/pi-pretty 0.6.25 → 0.6.27

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +47 -17
  3. package/dist/config.d.ts +16 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +30 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/fff.d.ts +16 -1
  8. package/dist/fff.d.ts.map +1 -1
  9. package/dist/fff.js +139 -33
  10. package/dist/fff.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +192 -43
  13. package/dist/index.js.map +1 -1
  14. package/dist/prompt-editor.d.ts +11 -0
  15. package/dist/prompt-editor.d.ts.map +1 -0
  16. package/dist/prompt-editor.js +78 -0
  17. package/dist/prompt-editor.js.map +1 -0
  18. package/dist/render.d.ts +9 -1
  19. package/dist/render.d.ts.map +1 -1
  20. package/dist/render.js +25 -9
  21. package/dist/render.js.map +1 -1
  22. package/dist/tools/bash.d.ts.map +1 -1
  23. package/dist/tools/bash.js +73 -30
  24. package/dist/tools/bash.js.map +1 -1
  25. package/dist/tools/find.d.ts.map +1 -1
  26. package/dist/tools/find.js +29 -10
  27. package/dist/tools/find.js.map +1 -1
  28. package/dist/tools/grep.d.ts.map +1 -1
  29. package/dist/tools/grep.js +20 -10
  30. package/dist/tools/grep.js.map +1 -1
  31. package/dist/tools/ls.d.ts.map +1 -1
  32. package/dist/tools/ls.js +10 -5
  33. package/dist/tools/ls.js.map +1 -1
  34. package/dist/tools/metrics.d.ts +6 -1
  35. package/dist/tools/metrics.d.ts.map +1 -1
  36. package/dist/tools/metrics.js +21 -8
  37. package/dist/tools/metrics.js.map +1 -1
  38. package/dist/tools/read.d.ts +2 -1
  39. package/dist/tools/read.d.ts.map +1 -1
  40. package/dist/tools/read.js +93 -30
  41. package/dist/tools/read.js.map +1 -1
  42. package/dist/types.d.ts +13 -3
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/working-indicator.d.ts +91 -26
  45. package/dist/working-indicator.d.ts.map +1 -1
  46. package/dist/working-indicator.js +259 -77
  47. package/dist/working-indicator.js.map +1 -1
  48. package/package.json +7 -6
  49. package/src/config.ts +51 -0
  50. package/src/fff.ts +158 -29
  51. package/src/index.ts +207 -47
  52. package/src/prompt-editor.ts +102 -0
  53. package/src/render.ts +30 -11
  54. package/src/tools/bash.ts +94 -32
  55. package/src/tools/find.ts +39 -17
  56. package/src/tools/grep.ts +26 -15
  57. package/src/tools/ls.ts +19 -10
  58. package/src/tools/metrics.ts +28 -8
  59. package/src/tools/read.ts +122 -30
  60. package/src/types.ts +14 -3
  61. package/src/working-indicator.ts +309 -88
package/src/config.ts CHANGED
@@ -77,6 +77,7 @@ export interface WorkingIndicatorConfig {
77
77
  mid?: string;
78
78
  high?: string;
79
79
  bold?: boolean;
80
+ /** @deprecated Retained for config compatibility; the working row no longer renders this hint. */
80
81
  hint?: boolean;
81
82
  /** Tint the indicator with a stable per-session accent color. */
82
83
  sessionAccent?: boolean;
@@ -86,6 +87,18 @@ export interface ThinkingIndicatorConfig {
86
87
  enabled?: boolean;
87
88
  }
88
89
 
90
+ export interface FffConfig {
91
+ /** Allow FFF to index the user's home directory when Pi starts there. */
92
+ enableHomeScanning?: boolean;
93
+ /** Allow FFF to index the filesystem root when Pi starts there. */
94
+ enableRootScanning?: boolean;
95
+ }
96
+
97
+ export interface FffScanSettings {
98
+ enableHomeScanning: boolean;
99
+ enableRootScanning: boolean;
100
+ }
101
+
89
102
  export interface PrettyConfig {
90
103
  background?: {
91
104
  tool?: string;
@@ -100,6 +113,7 @@ export interface PrettyConfig {
100
113
  cacheLimit?: number;
101
114
  workingIndicator?: WorkingIndicatorConfig;
102
115
  thinkingIndicator?: ThinkingIndicatorConfig;
116
+ fff?: FffConfig;
103
117
  }
104
118
 
105
119
  /** Normalize a comma-separated env value or JSON array into tool names. */
@@ -148,6 +162,14 @@ export function loadConfig(agentDir = getConfigDir()): PrettyConfig {
148
162
  if (maxHlChars) config.maxHlChars = maxHlChars;
149
163
  if (maxPreviewLines) config.maxPreviewLines = maxPreviewLines;
150
164
  if (cacheLimit) config.cacheLimit = cacheLimit;
165
+ const fff = parsed.fff;
166
+ if (fff && typeof fff === "object") {
167
+ const fffConfig: FffConfig = {};
168
+ const src = fff as Record<string, unknown>;
169
+ if (typeof src.enableHomeScanning === "boolean") fffConfig.enableHomeScanning = src.enableHomeScanning;
170
+ if (typeof src.enableRootScanning === "boolean") fffConfig.enableRootScanning = src.enableRootScanning;
171
+ if (Object.keys(fffConfig).length > 0) config.fff = fffConfig;
172
+ }
151
173
  const workingIndicator = parsed.workingIndicator;
152
174
  if (workingIndicator && typeof workingIndicator === "object") {
153
175
  const wi: WorkingIndicatorConfig = {};
@@ -185,6 +207,35 @@ export function getConfigDir(): string | undefined {
185
207
  return process.env.PRETTY_CONFIG_DIR ?? getDefaultAgentDir();
186
208
  }
187
209
 
210
+ function parseBooleanOverride(value: unknown): boolean | undefined {
211
+ if (typeof value === "boolean") return value;
212
+ if (typeof value !== "string") return undefined;
213
+ const normalized = value.trim().toLowerCase();
214
+ if (normalized === "1" || normalized === "true" || normalized === "on") return true;
215
+ if (normalized === "0" || normalized === "false" || normalized === "off") return false;
216
+ return undefined;
217
+ }
218
+
219
+ export function resolveFffScanSettings(
220
+ config: Pick<PrettyConfig, "fff">,
221
+ flags: {
222
+ enableHomeScanning?: boolean | string;
223
+ enableRootScanning?: boolean | string;
224
+ } = {},
225
+ env: NodeJS.ProcessEnv = process.env,
226
+ ): FffScanSettings {
227
+ const resolve = (
228
+ flagValue: boolean | string | undefined,
229
+ envName: string,
230
+ configValue: boolean | undefined,
231
+ ): boolean => parseBooleanOverride(flagValue) ?? parseBooleanOverride(env[envName]) ?? configValue ?? false;
232
+
233
+ return {
234
+ enableHomeScanning: resolve(flags.enableHomeScanning, "PRETTY_FFF_HOME_SCAN", config.fff?.enableHomeScanning),
235
+ enableRootScanning: resolve(flags.enableRootScanning, "PRETTY_FFF_ROOT_SCAN", config.fff?.enableRootScanning),
236
+ };
237
+ }
238
+
188
239
  /**
189
240
  * Merge env tool sets with config-file tool lists: a non-empty env set wins
190
241
  * over the config file (12-factor precedence); otherwise config applies.
package/src/fff.ts CHANGED
@@ -6,11 +6,44 @@
6
6
  * Graceful fallback: if FFF is not installed or fails, tools degrade to SDK.
7
7
  */
8
8
 
9
- import { mkdirSync } from "node:fs";
10
- import { join } from "node:path";
9
+ import { mkdirSync, realpathSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join, parse, resolve } from "node:path";
11
12
 
12
13
  import type { FileFinder as FffFileFinder, GrepCursor } from "@ff-labs/fff-node";
13
14
 
15
+ export interface FffInitOptions {
16
+ enableHomeDirScanning: boolean;
17
+ enableFsRootScanning: boolean;
18
+ }
19
+
20
+ export type FffInitFailureKind = "restricted-base-path" | "native";
21
+ export type FffRestrictedScanScope = "home" | "root";
22
+
23
+ export class FffInitError extends Error {
24
+ readonly kind: FffInitFailureKind;
25
+ readonly restrictedScope: FffRestrictedScanScope | undefined;
26
+
27
+ constructor(kind: FffInitFailureKind, message: string, restrictedScope?: FffRestrictedScanScope) {
28
+ super(message);
29
+ this.name = "FffInitError";
30
+ this.kind = kind;
31
+ this.restrictedScope = restrictedScope;
32
+ }
33
+ }
34
+
35
+ export function isFffRestrictedBasePathError(error: unknown, options: FffInitOptions): boolean {
36
+ if (!(error instanceof FffInitError) || error.kind !== "restricted-base-path") return false;
37
+ if (error.restrictedScope === "home") return !options.enableHomeDirScanning;
38
+ if (error.restrictedScope === "root") return !options.enableFsRootScanning;
39
+ return false;
40
+ }
41
+
42
+ const DEFAULT_FFF_INIT_OPTIONS: FffInitOptions = {
43
+ enableHomeDirScanning: false,
44
+ enableFsRootScanning: false,
45
+ };
46
+
14
47
  // ---------------------------------------------------------------------------
15
48
  // Cursor store — pagination cursors for grep
16
49
  // ---------------------------------------------------------------------------
@@ -53,6 +86,12 @@ type SharedFffState = {
53
86
  service: FffService;
54
87
  };
55
88
 
89
+ type FinderConfig = {
90
+ basePath: string;
91
+ enableHomeDirScanning: boolean;
92
+ enableFsRootScanning: boolean;
93
+ };
94
+
56
95
  type GlobalWithFffService = typeof globalThis & {
57
96
  [SHARED_FFF_SERVICE_KEY]?: SharedFffState;
58
97
  };
@@ -83,7 +122,9 @@ export class FffService {
83
122
 
84
123
  private fffModule: typeof import("@ff-labs/fff-node") | null = null;
85
124
  private dbDir: string | null = null;
125
+ private finderConfig: FinderConfig | null = null;
86
126
  private finderPromise: Promise<void> | null = null;
127
+ private finderPromiseConfig: FinderConfig | null = null;
87
128
 
88
129
  constructor(fffModule?: typeof import("@ff-labs/fff-node"), agentDir?: string) {
89
130
  this.configure(fffModule, agentDir);
@@ -130,47 +171,97 @@ export class FffService {
130
171
  }
131
172
  }
132
173
 
133
- async ensureFinder(cwd: string): Promise<void> {
134
- if (this.finder && !this.finder.isDestroyed) return;
135
- if (this.finderPromise) return this.finderPromise;
174
+ async ensureFinder(cwd: string, options: FffInitOptions = DEFAULT_FFF_INIT_OPTIONS): Promise<void> {
175
+ const requestedConfig = finderConfig(cwd, options);
136
176
 
137
- const promise = this._createFinder(cwd);
138
- this.finderPromise = promise;
139
- try {
140
- await promise;
141
- } finally {
142
- if (this.finderPromise === promise) this.finderPromise = null;
177
+ for (;;) {
178
+ if (this.finder && !this.finder.isDestroyed && sameFinderConfig(this.finderConfig, requestedConfig)) return;
179
+
180
+ const pending = this.finderPromise;
181
+ if (pending) {
182
+ const pendingConfig = this.finderPromiseConfig;
183
+ try {
184
+ await pending;
185
+ } catch (error) {
186
+ // A concurrent request for the same scope must observe the same
187
+ // failure. A different scope can retry after the pending attempt.
188
+ if (sameFinderConfig(pendingConfig, requestedConfig)) throw error;
189
+ }
190
+ continue;
191
+ }
192
+
193
+ const promise = this._createFinder(cwd, options, requestedConfig);
194
+ this.finderPromise = promise;
195
+ this.finderPromiseConfig = requestedConfig;
196
+ try {
197
+ await promise;
198
+ return;
199
+ } finally {
200
+ if (this.finderPromise === promise) {
201
+ this.finderPromise = null;
202
+ this.finderPromiseConfig = null;
203
+ }
204
+ }
143
205
  }
144
206
  }
145
207
 
146
- private async _createFinder(cwd: string): Promise<void> {
208
+ private async _createFinder(cwd: string, options: FffInitOptions, config: FinderConfig): Promise<void> {
147
209
  if (!this.fffModule) return;
148
210
 
149
- if (this.finder && !this.finder.isDestroyed) {
150
- this.finder.destroy();
151
- this.finder = null;
211
+ const result = this.createFinder(cwd, this.dbDir, options);
212
+ if (!result.ok) {
213
+ const kind = isRestrictedBasePathMessage(result.error) ? "restricted-base-path" : "native";
214
+ throw new FffInitError(
215
+ kind,
216
+ result.error,
217
+ kind === "restricted-base-path" ? restrictedScanScope(cwd) : undefined,
218
+ );
152
219
  }
153
220
 
154
- const result = this.createFinder(cwd, this.dbDir);
155
- if (!result.ok) {
156
- throw new Error(`FFF init failed: ${result.error}`);
221
+ const candidate = result.value;
222
+ let scan: Awaited<ReturnType<typeof candidate.waitForScan>>;
223
+ try {
224
+ scan = await candidate.waitForScan(15_000);
225
+ } catch (error) {
226
+ if (!candidate.isDestroyed) {
227
+ try {
228
+ candidate.destroy();
229
+ } catch {
230
+ // Preserve the scan failure; the candidate is no longer selected.
231
+ }
232
+ }
233
+ throw error;
157
234
  }
158
235
 
159
- this.finder = result.value;
160
- const scan = await this.finder.waitForScan(15_000);
236
+ const previous = this.finder;
237
+ this.finder = candidate;
161
238
  this.partialIndex = scan.ok && !scan.value;
239
+ this.finderConfig = config;
240
+ if (previous && previous !== candidate && !previous.isDestroyed) {
241
+ try {
242
+ previous.destroy();
243
+ } catch {
244
+ // Keep the replacement active; native cleanup is best effort.
245
+ }
246
+ }
162
247
  }
163
248
 
164
249
  private createFinder(
165
250
  cwd: string,
166
251
  dbDir: string | null,
252
+ options: FffInitOptions,
167
253
  ): ReturnType<typeof import("@ff-labs/fff-node").FileFinder.create> {
168
254
  if (!this.fffModule) throw new Error("FFF module is not loaded");
169
- const result = this.fffModule.FileFinder.create({
255
+ const commonOptions = {
170
256
  basePath: cwd,
257
+ aiMode: true,
258
+ enableHomeDirScanning: options.enableHomeDirScanning,
259
+ enableFsRootScanning: options.enableFsRootScanning,
260
+ };
261
+ const result = this.fffModule.FileFinder.create({
262
+ ...commonOptions,
171
263
  frecencyDbPath: dbDir ? join(dbDir, "frecency.mdb") : "",
172
264
  historyDbPath: dbDir ? join(dbDir, "history.mdb") : "",
173
- aiMode: true,
174
265
  });
175
266
  if (result.ok || !isAlreadyOpenError(result.error) || !dbDir) return result;
176
267
 
@@ -181,20 +272,23 @@ export class FffService {
181
272
  return result;
182
273
  }
183
274
  return this.fffModule.FileFinder.create({
184
- basePath: cwd,
275
+ ...commonOptions,
185
276
  frecencyDbPath: join(isolatedDir, "frecency.mdb"),
186
277
  historyDbPath: join(isolatedDir, "history.mdb"),
187
- aiMode: true,
188
278
  });
189
279
  }
190
280
 
191
281
  destroy(): void {
192
- if (this.finder && !this.finder.isDestroyed) {
193
- this.finder.destroy();
194
- this.finder = null;
195
- }
196
- this.partialIndex = false;
282
+ this.disposeFinder();
197
283
  this.finderPromise = null;
284
+ this.finderPromiseConfig = null;
285
+ }
286
+
287
+ private disposeFinder(): void {
288
+ if (this.finder && !this.finder.isDestroyed) this.finder.destroy();
289
+ this.finder = null;
290
+ this.finderConfig = null;
291
+ this.partialIndex = false;
198
292
  }
199
293
 
200
294
  getFinder(): import("@ff-labs/fff-node").FileFinder | null {
@@ -206,6 +300,41 @@ export class FffService {
206
300
  }
207
301
  }
208
302
 
303
+ function finderConfig(cwd: string, options: FffInitOptions): FinderConfig {
304
+ return {
305
+ basePath: canonicalPath(cwd),
306
+ enableHomeDirScanning: options.enableHomeDirScanning,
307
+ enableFsRootScanning: options.enableFsRootScanning,
308
+ };
309
+ }
310
+
311
+ function sameFinderConfig(left: FinderConfig | null, right: FinderConfig): boolean {
312
+ return (
313
+ left?.basePath === right.basePath &&
314
+ left.enableHomeDirScanning === right.enableHomeDirScanning &&
315
+ left.enableFsRootScanning === right.enableFsRootScanning
316
+ );
317
+ }
318
+
209
319
  function isAlreadyOpenError(error: string): boolean {
210
320
  return /environment already open/i.test(error);
211
321
  }
322
+
323
+ function isRestrictedBasePathMessage(error: string): boolean {
324
+ return /certain FFF features in a file system root or home directories/i.test(error);
325
+ }
326
+
327
+ function restrictedScanScope(cwd: string): FffRestrictedScanScope | undefined {
328
+ const basePath = canonicalPath(cwd);
329
+ if (basePath === parse(basePath).root) return "root";
330
+ if (basePath === canonicalPath(homedir())) return "home";
331
+ return undefined;
332
+ }
333
+
334
+ function canonicalPath(path: string): string {
335
+ try {
336
+ return realpathSync.native(path);
337
+ } catch {
338
+ return resolve(path);
339
+ }
340
+ }