@heyhuynhgiabuu/pi-pretty 0.3.0 → 0.3.2
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 +9 -1
- package/package.json +1 -1
- package/release-notes/v0.3.2.md +34 -0
- package/src/fff-helpers.ts +73 -0
- package/src/index.ts +171 -89
- package/test/fff-integration.test.ts +455 -0
- package/test/image-rendering.test.ts +165 -0
package/README.md
CHANGED
|
@@ -41,7 +41,15 @@ pi -e ./src/index.ts
|
|
|
41
41
|
## Terminal support for inline images
|
|
42
42
|
|
|
43
43
|
Inline image previews are supported in **Ghostty**, **Kitty**, **iTerm2**, and **WezTerm**.
|
|
44
|
-
When running in **tmux**, pi-pretty uses passthrough escape sequences
|
|
44
|
+
When running in **tmux**, pi-pretty uses passthrough escape sequences.
|
|
45
|
+
|
|
46
|
+
> tmux must allow passthrough. Enable it with:
|
|
47
|
+
>
|
|
48
|
+
> ```tmux
|
|
49
|
+
> set -g allow-passthrough on
|
|
50
|
+
> ```
|
|
51
|
+
>
|
|
52
|
+
> (or run once in a session: `tmux set -g allow-passthrough on`)
|
|
45
53
|
|
|
46
54
|
## Configuration
|
|
47
55
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyhuynhgiabuu/pi-pretty",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# pi-pretty v0.3.2
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
This patch release fixes inline image rendering reliability when running `pi-pretty` inside `tmux`.
|
|
5
|
+
|
|
6
|
+
## What changed
|
|
7
|
+
- Fixed terminal detection in tmux for:
|
|
8
|
+
- Kitty (`KITTY_WINDOW_ID`, `KITTY_PID`)
|
|
9
|
+
- WezTerm (`WEZTERM_EXECUTABLE`, `WEZTERM_CONFIG_DIR`, `WEZTERM_CONFIG_FILE`)
|
|
10
|
+
- Added tmux fallback detection via:
|
|
11
|
+
- `tmux display-message -p "#{client_termname}"`
|
|
12
|
+
- Replaced static tmux detection with runtime detection to avoid stale module-load state.
|
|
13
|
+
- Added explicit warning when tmux passthrough is disabled:
|
|
14
|
+
- `tmux allow-passthrough is off. Run: tmux set -g allow-passthrough on`
|
|
15
|
+
- Added non-PNG warning for Kitty/Ghostty image rendering path.
|
|
16
|
+
- Added test coverage for image protocol detection and tmux passthrough warning behavior.
|
|
17
|
+
- Updated README with tmux passthrough setup instructions.
|
|
18
|
+
|
|
19
|
+
## Files
|
|
20
|
+
- `src/index.ts`
|
|
21
|
+
- `test/image-rendering.test.ts`
|
|
22
|
+
- `README.md`
|
|
23
|
+
|
|
24
|
+
## Verification
|
|
25
|
+
- `npm run typecheck` ✅
|
|
26
|
+
- `npm test` ✅ (46 tests)
|
|
27
|
+
- `npm run lint` ⚠️ fails due to existing Biome diagnostics in legacy code paths (pre-existing, not introduced in this patch).
|
|
28
|
+
|
|
29
|
+
## Upgrade notes
|
|
30
|
+
When using `tmux`, enable passthrough:
|
|
31
|
+
|
|
32
|
+
```tmux
|
|
33
|
+
set -g allow-passthrough on
|
|
34
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFF helper functions — extracted for testability.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions and classes used by the FFF integration in index.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Store for FFF grep pagination cursors.
|
|
9
|
+
* Evicts oldest entry when exceeding maxSize.
|
|
10
|
+
*/
|
|
11
|
+
export class CursorStore {
|
|
12
|
+
private cursors = new Map<string, any>();
|
|
13
|
+
private counter = 0;
|
|
14
|
+
private maxSize: number;
|
|
15
|
+
|
|
16
|
+
constructor(maxSize = 200) {
|
|
17
|
+
this.maxSize = maxSize;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
store(cursor: any): string {
|
|
21
|
+
const id = `fff_c${++this.counter}`;
|
|
22
|
+
this.cursors.set(id, cursor);
|
|
23
|
+
if (this.cursors.size > this.maxSize) {
|
|
24
|
+
const first = this.cursors.keys().next().value;
|
|
25
|
+
if (first) this.cursors.delete(first);
|
|
26
|
+
}
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get(id: string): any | undefined {
|
|
31
|
+
return this.cursors.get(id);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get size(): number {
|
|
35
|
+
return this.cursors.size;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert FFF GrepResult items to ripgrep-style "file:line:content" text.
|
|
41
|
+
* This ensures pi-pretty's renderGrepResults works unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export function fffFormatGrepText(items: any[], limit: number): string {
|
|
44
|
+
const capped = items.slice(0, limit);
|
|
45
|
+
if (!capped.length) return "No matches found";
|
|
46
|
+
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
let currentFile = "";
|
|
49
|
+
|
|
50
|
+
for (const match of capped) {
|
|
51
|
+
if (match.relativePath !== currentFile) {
|
|
52
|
+
if (currentFile) lines.push("");
|
|
53
|
+
currentFile = match.relativePath;
|
|
54
|
+
}
|
|
55
|
+
if (match.contextBefore?.length) {
|
|
56
|
+
const startLine = match.lineNumber - match.contextBefore.length;
|
|
57
|
+
for (let i = 0; i < match.contextBefore.length; i++) {
|
|
58
|
+
lines.push(`${match.relativePath}-${startLine + i}-${match.contextBefore[i]}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const content =
|
|
62
|
+
match.lineContent.length > 500 ? `${match.lineContent.slice(0, 500)}...` : match.lineContent;
|
|
63
|
+
lines.push(`${match.relativePath}:${match.lineNumber}:${content}`);
|
|
64
|
+
if (match.contextAfter?.length) {
|
|
65
|
+
const startLine = match.lineNumber + 1;
|
|
66
|
+
for (let i = 0; i < match.contextAfter.length; i++) {
|
|
67
|
+
lines.push(`${match.relativePath}-${startLine + i}-${match.contextAfter[i]}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,12 +23,15 @@
|
|
|
23
23
|
* • Large-file fallback (skip highlighting, still show line numbers)
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import * as childProcess from "node:child_process";
|
|
26
27
|
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
27
28
|
import { basename, dirname, extname, join, relative } from "node:path";
|
|
28
29
|
|
|
29
30
|
import { codeToANSI } from "@shikijs/cli";
|
|
30
31
|
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
31
32
|
|
|
33
|
+
import { CursorStore, fffFormatGrepText } from "./fff-helpers.js";
|
|
34
|
+
|
|
32
35
|
// ---------------------------------------------------------------------------
|
|
33
36
|
// Config
|
|
34
37
|
// ---------------------------------------------------------------------------
|
|
@@ -215,7 +218,45 @@ function lang(fp: string): BundledLanguage | undefined {
|
|
|
215
218
|
|
|
216
219
|
type ImageProtocol = "iterm2" | "kitty" | "none";
|
|
217
220
|
|
|
218
|
-
|
|
221
|
+
let _tmuxClientTermCache: string | null | undefined;
|
|
222
|
+
let _tmuxAllowPassthroughCache: boolean | null | undefined;
|
|
223
|
+
let _tmuxClientTermOverrideForTests: string | null | undefined;
|
|
224
|
+
let _tmuxAllowPassthroughOverrideForTests: boolean | null | undefined;
|
|
225
|
+
|
|
226
|
+
function isTmuxSession(): boolean {
|
|
227
|
+
return !!process.env.TMUX || /^(tmux|screen)/.test(process.env.TERM ?? "");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizeTerminalName(term: string): string {
|
|
231
|
+
const t = term.toLowerCase();
|
|
232
|
+
if (t.includes("kitty")) return "kitty";
|
|
233
|
+
if (t.includes("ghostty")) return "ghostty";
|
|
234
|
+
if (t.includes("wezterm")) return "WezTerm";
|
|
235
|
+
if (t.includes("iterm")) return "iTerm.app";
|
|
236
|
+
if (t.includes("mintty")) return "mintty";
|
|
237
|
+
return term;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function readTmuxClientTerm(): string | null {
|
|
241
|
+
if (_tmuxClientTermOverrideForTests !== undefined) {
|
|
242
|
+
return _tmuxClientTermOverrideForTests ? normalizeTerminalName(_tmuxClientTermOverrideForTests) : null;
|
|
243
|
+
}
|
|
244
|
+
if (!isTmuxSession()) return null;
|
|
245
|
+
if (_tmuxClientTermCache !== undefined) return _tmuxClientTermCache;
|
|
246
|
+
try {
|
|
247
|
+
const term = childProcess
|
|
248
|
+
.execFileSync("tmux", ["display-message", "-p", "#{client_termname}"], {
|
|
249
|
+
encoding: "utf8",
|
|
250
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
251
|
+
timeout: 200,
|
|
252
|
+
})
|
|
253
|
+
.trim();
|
|
254
|
+
_tmuxClientTermCache = term ? normalizeTerminalName(term) : null;
|
|
255
|
+
} catch {
|
|
256
|
+
_tmuxClientTermCache = null;
|
|
257
|
+
}
|
|
258
|
+
return _tmuxClientTermCache;
|
|
259
|
+
}
|
|
219
260
|
|
|
220
261
|
/**
|
|
221
262
|
* Detect the outer terminal when running inside tmux.
|
|
@@ -223,27 +264,34 @@ const IS_TMUX = !!process.env.TMUX;
|
|
|
223
264
|
* the environment of the tmux server or can be inferred.
|
|
224
265
|
*/
|
|
225
266
|
function getOuterTerminal(): string {
|
|
226
|
-
//
|
|
227
|
-
const term = process.env.TERM_PROGRAM ?? "";
|
|
228
|
-
if (term !== "tmux" && term !== "screen") return term;
|
|
229
|
-
|
|
230
|
-
// Inside tmux: check common env vars that leak through
|
|
231
|
-
// Ghostty sets this; iTerm2 sets LC_TERMINAL
|
|
267
|
+
// Environment hints that often survive inside tmux
|
|
232
268
|
if (process.env.LC_TERMINAL === "iTerm2") return "iTerm.app";
|
|
233
|
-
|
|
234
|
-
// TERM_PROGRAM_VERSION sometimes survives into tmux
|
|
235
|
-
// Try to detect via COLORTERM or other hints
|
|
236
269
|
if (process.env.GHOSTTY_RESOURCES_DIR) return "ghostty";
|
|
270
|
+
if (process.env.KITTY_WINDOW_ID || process.env.KITTY_PID) return "kitty";
|
|
271
|
+
if (process.env.WEZTERM_EXECUTABLE || process.env.WEZTERM_CONFIG_DIR || process.env.WEZTERM_CONFIG_FILE) {
|
|
272
|
+
return "WezTerm";
|
|
273
|
+
}
|
|
237
274
|
|
|
238
|
-
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
return "unknown-modern";
|
|
275
|
+
const termProgram = process.env.TERM_PROGRAM ?? "";
|
|
276
|
+
if (termProgram && termProgram !== "tmux" && termProgram !== "screen") {
|
|
277
|
+
return normalizeTerminalName(termProgram);
|
|
242
278
|
}
|
|
243
|
-
|
|
279
|
+
|
|
280
|
+
const tmuxClientTerm = readTmuxClientTerm();
|
|
281
|
+
if (tmuxClientTerm) return tmuxClientTerm;
|
|
282
|
+
|
|
283
|
+
const term = process.env.TERM ?? "";
|
|
284
|
+
if (term) return normalizeTerminalName(term);
|
|
285
|
+
if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit") return "unknown-modern";
|
|
286
|
+
return termProgram;
|
|
244
287
|
}
|
|
245
288
|
|
|
246
289
|
function detectImageProtocol(): ImageProtocol {
|
|
290
|
+
const forced = (process.env.PRETTY_IMAGE_PROTOCOL ?? "").toLowerCase();
|
|
291
|
+
if (forced === "kitty" || forced === "iterm2" || forced === "none") {
|
|
292
|
+
return forced;
|
|
293
|
+
}
|
|
294
|
+
|
|
247
295
|
const term = getOuterTerminal();
|
|
248
296
|
// Ghostty and Kitty use the Kitty graphics protocol
|
|
249
297
|
if (term === "ghostty" || term === "kitty") return "kitty";
|
|
@@ -253,18 +301,67 @@ function detectImageProtocol(): ImageProtocol {
|
|
|
253
301
|
return "none";
|
|
254
302
|
}
|
|
255
303
|
|
|
304
|
+
function tmuxAllowsPassthrough(): boolean | null {
|
|
305
|
+
if (_tmuxAllowPassthroughOverrideForTests !== undefined) return _tmuxAllowPassthroughOverrideForTests;
|
|
306
|
+
if (!isTmuxSession()) return null;
|
|
307
|
+
if (_tmuxAllowPassthroughCache !== undefined) return _tmuxAllowPassthroughCache;
|
|
308
|
+
try {
|
|
309
|
+
const value = childProcess
|
|
310
|
+
.execFileSync("tmux", ["show-options", "-gv", "allow-passthrough"], {
|
|
311
|
+
encoding: "utf8",
|
|
312
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
313
|
+
timeout: 200,
|
|
314
|
+
})
|
|
315
|
+
.trim()
|
|
316
|
+
.toLowerCase();
|
|
317
|
+
_tmuxAllowPassthroughCache = value === "on" || value === "all";
|
|
318
|
+
} catch {
|
|
319
|
+
_tmuxAllowPassthroughCache = null;
|
|
320
|
+
}
|
|
321
|
+
return _tmuxAllowPassthroughCache;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function getTmuxPassthroughWarning(protocol: ImageProtocol): string | null {
|
|
325
|
+
if (!isTmuxSession() || protocol === "none") return null;
|
|
326
|
+
if (tmuxAllowsPassthrough() === false) {
|
|
327
|
+
return "tmux allow-passthrough is off. Run: tmux set -g allow-passthrough on";
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
256
332
|
/**
|
|
257
333
|
* Wrap escape sequence for tmux passthrough.
|
|
258
334
|
* tmux requires: ESC Ptmux; <escaped-sequence> ESC \
|
|
259
335
|
* Inner ESC chars must be doubled.
|
|
260
336
|
*/
|
|
261
337
|
function tmuxWrap(seq: string): string {
|
|
262
|
-
if (!
|
|
338
|
+
if (!isTmuxSession()) return seq;
|
|
263
339
|
// Double all ESC chars inside the sequence
|
|
264
340
|
const escaped = seq.split("\x1b").join("\x1b\x1b");
|
|
265
341
|
return `\x1bPtmux;${escaped}\x1b\\`;
|
|
266
342
|
}
|
|
267
343
|
|
|
344
|
+
export const __imageInternals = {
|
|
345
|
+
isTmuxSession,
|
|
346
|
+
getOuterTerminal,
|
|
347
|
+
detectImageProtocol,
|
|
348
|
+
tmuxWrap,
|
|
349
|
+
tmuxAllowsPassthrough,
|
|
350
|
+
getTmuxPassthroughWarning,
|
|
351
|
+
setTmuxClientTermOverrideForTests: (value: string | null | undefined) => {
|
|
352
|
+
_tmuxClientTermOverrideForTests = value;
|
|
353
|
+
},
|
|
354
|
+
setTmuxAllowPassthroughOverrideForTests: (value: boolean | null | undefined) => {
|
|
355
|
+
_tmuxAllowPassthroughOverrideForTests = value;
|
|
356
|
+
},
|
|
357
|
+
resetCachesForTests: () => {
|
|
358
|
+
_tmuxClientTermCache = undefined;
|
|
359
|
+
_tmuxAllowPassthroughCache = undefined;
|
|
360
|
+
_tmuxClientTermOverrideForTests = undefined;
|
|
361
|
+
_tmuxAllowPassthroughOverrideForTests = undefined;
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
|
|
268
365
|
/**
|
|
269
366
|
* Render base64 image inline using iTerm2 inline image protocol.
|
|
270
367
|
* Protocol: ESC ] 1337 ; File=[args] : base64data BEL
|
|
@@ -676,25 +773,6 @@ async function renderGrepResults(text: string, pattern: string): Promise<string>
|
|
|
676
773
|
// If not, falls back to wrapping SDK tools (current behavior).
|
|
677
774
|
// ---------------------------------------------------------------------------
|
|
678
775
|
|
|
679
|
-
class CursorStore {
|
|
680
|
-
private cursors = new Map<string, any>();
|
|
681
|
-
private counter = 0;
|
|
682
|
-
|
|
683
|
-
store(cursor: any): string {
|
|
684
|
-
const id = `fff_c${++this.counter}`;
|
|
685
|
-
this.cursors.set(id, cursor);
|
|
686
|
-
if (this.cursors.size > 200) {
|
|
687
|
-
const first = this.cursors.keys().next().value;
|
|
688
|
-
if (first) this.cursors.delete(first);
|
|
689
|
-
}
|
|
690
|
-
return id;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
get(id: string): any | undefined {
|
|
694
|
-
return this.cursors.get(id);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
776
|
const _cursorStore = new CursorStore();
|
|
699
777
|
let _fffModule: any = null;
|
|
700
778
|
let _fffFinder: any = null;
|
|
@@ -730,47 +808,21 @@ function fffDestroy(): void {
|
|
|
730
808
|
_fffPartialIndex = false;
|
|
731
809
|
}
|
|
732
810
|
|
|
733
|
-
/**
|
|
734
|
-
* Convert FFF GrepResult items to ripgrep-style "file:line:content" text.
|
|
735
|
-
* This ensures pi-pretty's renderGrepResults works unchanged.
|
|
736
|
-
*/
|
|
737
|
-
function fffFormatGrepText(items: any[], limit: number): string {
|
|
738
|
-
const capped = items.slice(0, limit);
|
|
739
|
-
if (!capped.length) return "No matches found";
|
|
740
|
-
|
|
741
|
-
const lines: string[] = [];
|
|
742
|
-
let currentFile = "";
|
|
743
|
-
|
|
744
|
-
for (const match of capped) {
|
|
745
|
-
if (match.relativePath !== currentFile) {
|
|
746
|
-
if (currentFile) lines.push("");
|
|
747
|
-
currentFile = match.relativePath;
|
|
748
|
-
}
|
|
749
|
-
if (match.contextBefore?.length) {
|
|
750
|
-
const startLine = match.lineNumber - match.contextBefore.length;
|
|
751
|
-
for (let i = 0; i < match.contextBefore.length; i++) {
|
|
752
|
-
lines.push(`${match.relativePath}-${startLine + i}-${match.contextBefore[i]}`);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
const content =
|
|
756
|
-
match.lineContent.length > 500 ? `${match.lineContent.slice(0, 500)}...` : match.lineContent;
|
|
757
|
-
lines.push(`${match.relativePath}:${match.lineNumber}:${content}`);
|
|
758
|
-
if (match.contextAfter?.length) {
|
|
759
|
-
const startLine = match.lineNumber + 1;
|
|
760
|
-
for (let i = 0; i < match.contextAfter.length; i++) {
|
|
761
|
-
lines.push(`${match.relativePath}-${startLine + i}-${match.contextAfter[i]}`);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
return lines.join("\n");
|
|
767
|
-
}
|
|
768
|
-
|
|
769
811
|
// ---------------------------------------------------------------------------
|
|
770
812
|
// Extension entry point
|
|
771
813
|
// ---------------------------------------------------------------------------
|
|
772
814
|
|
|
773
|
-
|
|
815
|
+
/**
|
|
816
|
+
* Dependencies that can be injected for testing.
|
|
817
|
+
* In production, omit `deps` — the extension uses require() to load them.
|
|
818
|
+
*/
|
|
819
|
+
export interface PiPrettyDeps {
|
|
820
|
+
sdk: any;
|
|
821
|
+
TextComponent: any;
|
|
822
|
+
fffModule?: any;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
|
|
774
826
|
let createReadTool: any;
|
|
775
827
|
let createBashTool: any;
|
|
776
828
|
let createLsTool: any;
|
|
@@ -780,16 +832,31 @@ export default function piPrettyExtension(pi: any): void {
|
|
|
780
832
|
|
|
781
833
|
let sdk: any;
|
|
782
834
|
|
|
783
|
-
|
|
784
|
-
|
|
835
|
+
if (deps) {
|
|
836
|
+
// Test path: use injected dependencies, reset module state
|
|
837
|
+
sdk = deps.sdk;
|
|
785
838
|
createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
|
|
786
839
|
createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
|
|
787
840
|
createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
|
|
788
841
|
createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
|
|
789
842
|
createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
|
|
790
|
-
TextComponent =
|
|
791
|
-
|
|
792
|
-
|
|
843
|
+
TextComponent = deps.TextComponent;
|
|
844
|
+
_fffModule = deps.fffModule ?? null;
|
|
845
|
+
_fffFinder = null;
|
|
846
|
+
_fffPartialIndex = false;
|
|
847
|
+
_fffDbDir = null;
|
|
848
|
+
} else {
|
|
849
|
+
try {
|
|
850
|
+
sdk = require("@mariozechner/pi-coding-agent");
|
|
851
|
+
createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
|
|
852
|
+
createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
|
|
853
|
+
createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
|
|
854
|
+
createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
|
|
855
|
+
createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
|
|
856
|
+
TextComponent = require("@mariozechner/pi-tui").Text;
|
|
857
|
+
} catch {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
793
860
|
}
|
|
794
861
|
if (!createReadTool || !TextComponent) return;
|
|
795
862
|
|
|
@@ -802,16 +869,24 @@ export default function piPrettyExtension(pi: any): void {
|
|
|
802
869
|
// ===================================================================
|
|
803
870
|
|
|
804
871
|
const getAgentDir = (sdk as any).getAgentDir;
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
872
|
+
if (!deps) {
|
|
873
|
+
// Only try require() in production — tests inject fffModule via deps
|
|
874
|
+
try {
|
|
875
|
+
_fffModule = require("@ff-labs/fff-node");
|
|
876
|
+
if (getAgentDir) {
|
|
877
|
+
_fffDbDir = join(getAgentDir(), "fff");
|
|
878
|
+
try {
|
|
879
|
+
mkdirSync(_fffDbDir, { recursive: true });
|
|
880
|
+
} catch {}
|
|
881
|
+
}
|
|
882
|
+
} catch {
|
|
883
|
+
/* FFF not installed — SDK tools will be used */
|
|
812
884
|
}
|
|
813
|
-
}
|
|
814
|
-
|
|
885
|
+
} else if (_fffModule && getAgentDir) {
|
|
886
|
+
_fffDbDir = join(getAgentDir(), "fff");
|
|
887
|
+
try {
|
|
888
|
+
mkdirSync(_fffDbDir, { recursive: true });
|
|
889
|
+
} catch {}
|
|
815
890
|
}
|
|
816
891
|
|
|
817
892
|
pi.on("session_start", async (_event: any, ctx: any) => {
|
|
@@ -936,9 +1011,16 @@ export default function piPrettyExtension(pi: any): void {
|
|
|
936
1011
|
out.push(rule(tw));
|
|
937
1012
|
|
|
938
1013
|
const protocol = detectImageProtocol();
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
out.push(
|
|
1014
|
+
const passthroughWarning = getTmuxPassthroughWarning(protocol);
|
|
1015
|
+
if (passthroughWarning) {
|
|
1016
|
+
out.push(` ${FG_YELLOW}${passthroughWarning}${RST}`);
|
|
1017
|
+
} else if (protocol === "kitty") {
|
|
1018
|
+
if (d.mimeType && d.mimeType !== "image/png") {
|
|
1019
|
+
out.push(` ${FG_YELLOW}Kitty/Ghostty inline preview currently supports PNG payloads (got ${d.mimeType})${RST}`);
|
|
1020
|
+
} else {
|
|
1021
|
+
const imgCols = Math.min(tw - 4, 80);
|
|
1022
|
+
out.push(renderKittyImage(d.data, { cols: imgCols }));
|
|
1023
|
+
}
|
|
942
1024
|
} else if (protocol === "iterm2") {
|
|
943
1025
|
const imgWidth = Math.min(tw - 4, 80);
|
|
944
1026
|
out.push(
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for pi-pretty FFF integration vs SDK fallback.
|
|
3
|
+
*
|
|
4
|
+
* 1. Unit tests for CursorStore + fffFormatGrepText (extracted helpers)
|
|
5
|
+
* 2. Integration tests via dependency injection (PiPrettyDeps)
|
|
6
|
+
* - SDK fallback path (no FFF)
|
|
7
|
+
* - FFF path (FFF injected)
|
|
8
|
+
* - Graceful degradation (FFF fails → SDK fallback)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
12
|
+
import { CursorStore, fffFormatGrepText } from "../src/fff-helpers.js";
|
|
13
|
+
import piPrettyExtension, { type PiPrettyDeps } from "../src/index.js";
|
|
14
|
+
|
|
15
|
+
// =========================================================================
|
|
16
|
+
// 1. Unit tests — pure functions
|
|
17
|
+
// =========================================================================
|
|
18
|
+
|
|
19
|
+
describe("CursorStore", () => {
|
|
20
|
+
it("stores and retrieves a cursor", () => {
|
|
21
|
+
const store = new CursorStore();
|
|
22
|
+
const cursor = { page: 2, offset: 50 };
|
|
23
|
+
const id = store.store(cursor);
|
|
24
|
+
expect(id).toMatch(/^fff_c\d+$/);
|
|
25
|
+
expect(store.get(id)).toBe(cursor);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns undefined for unknown id", () => {
|
|
29
|
+
expect(new CursorStore().get("fff_c999")).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("increments ids sequentially", () => {
|
|
33
|
+
const store = new CursorStore();
|
|
34
|
+
const n1 = Number.parseInt(store.store("a").slice(5), 10);
|
|
35
|
+
const n2 = Number.parseInt(store.store("b").slice(5), 10);
|
|
36
|
+
expect(n2).toBe(n1 + 1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("evicts oldest when exceeding maxSize", () => {
|
|
40
|
+
const store = new CursorStore(3);
|
|
41
|
+
const id1 = store.store("a");
|
|
42
|
+
store.store("b"); store.store("c");
|
|
43
|
+
expect(store.size).toBe(3);
|
|
44
|
+
store.store("d");
|
|
45
|
+
expect(store.size).toBe(3);
|
|
46
|
+
expect(store.get(id1)).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("default maxSize is 200", () => {
|
|
50
|
+
const store = new CursorStore();
|
|
51
|
+
const ids: string[] = [];
|
|
52
|
+
for (let i = 0; i < 201; i++) ids.push(store.store(i));
|
|
53
|
+
expect(store.size).toBe(200);
|
|
54
|
+
expect(store.get(ids[0])).toBeUndefined();
|
|
55
|
+
expect(store.get(ids[200])).toBe(200);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("fffFormatGrepText", () => {
|
|
60
|
+
it("empty → 'No matches found'", () => {
|
|
61
|
+
expect(fffFormatGrepText([], 100)).toBe("No matches found");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("single match → file:line:content", () => {
|
|
65
|
+
const items = [{ relativePath: "src/a.ts", lineNumber: 42, lineContent: "const x = 1;" }];
|
|
66
|
+
expect(fffFormatGrepText(items, 100)).toBe("src/a.ts:42:const x = 1;");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("groups by file with blank separator", () => {
|
|
70
|
+
const items = [
|
|
71
|
+
{ relativePath: "a.ts", lineNumber: 1, lineContent: "L1" },
|
|
72
|
+
{ relativePath: "a.ts", lineNumber: 5, lineContent: "L5" },
|
|
73
|
+
{ relativePath: "b.ts", lineNumber: 10, lineContent: "LB" },
|
|
74
|
+
];
|
|
75
|
+
expect(fffFormatGrepText(items, 100).split("\n")).toEqual(["a.ts:1:L1", "a.ts:5:L5", "", "b.ts:10:LB"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("truncates >500 char lines", () => {
|
|
79
|
+
const items = [{ relativePath: "a.ts", lineNumber: 1, lineContent: "x".repeat(600) }];
|
|
80
|
+
expect(fffFormatGrepText(items, 100)).toBe(`a.ts:1:${"x".repeat(500)}...`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("respects limit", () => {
|
|
84
|
+
const items = [
|
|
85
|
+
{ relativePath: "a.ts", lineNumber: 1, lineContent: "one" },
|
|
86
|
+
{ relativePath: "a.ts", lineNumber: 2, lineContent: "two" },
|
|
87
|
+
{ relativePath: "a.ts", lineNumber: 3, lineContent: "three" },
|
|
88
|
+
];
|
|
89
|
+
expect(fffFormatGrepText(items, 2).split("\n")).toHaveLength(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("contextBefore with dash format", () => {
|
|
93
|
+
const items = [{
|
|
94
|
+
relativePath: "a.ts", lineNumber: 5, lineContent: "match",
|
|
95
|
+
contextBefore: ["before1", "before2"],
|
|
96
|
+
}];
|
|
97
|
+
const lines = fffFormatGrepText(items, 100).split("\n");
|
|
98
|
+
expect(lines[0]).toBe("a.ts-3-before1");
|
|
99
|
+
expect(lines[1]).toBe("a.ts-4-before2");
|
|
100
|
+
expect(lines[2]).toBe("a.ts:5:match");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("contextAfter with dash format", () => {
|
|
104
|
+
const items = [{
|
|
105
|
+
relativePath: "a.ts", lineNumber: 5, lineContent: "match",
|
|
106
|
+
contextAfter: ["after1"],
|
|
107
|
+
}];
|
|
108
|
+
const lines = fffFormatGrepText(items, 100).split("\n");
|
|
109
|
+
expect(lines[0]).toBe("a.ts:5:match");
|
|
110
|
+
expect(lines[1]).toBe("a.ts-6-after1");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// =========================================================================
|
|
115
|
+
// 2. Integration tests — via PiPrettyDeps injection
|
|
116
|
+
// =========================================================================
|
|
117
|
+
|
|
118
|
+
// Mock SDK tool factories
|
|
119
|
+
function mockToolFactory(exec: ReturnType<typeof vi.fn>) {
|
|
120
|
+
return (_cwd: string) => ({
|
|
121
|
+
name: "mock",
|
|
122
|
+
description: "mock",
|
|
123
|
+
parameters: { type: "object", properties: {} },
|
|
124
|
+
execute: exec,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Mock FFF finder
|
|
129
|
+
function mkFinder(overrides?: Record<string, any>) {
|
|
130
|
+
return {
|
|
131
|
+
isDestroyed: false,
|
|
132
|
+
waitForScan: vi.fn().mockResolvedValue({ ok: true, value: true }),
|
|
133
|
+
fileSearch: vi.fn().mockReturnValue({
|
|
134
|
+
ok: true,
|
|
135
|
+
value: {
|
|
136
|
+
items: [{ relativePath: "src/index.ts" }, { relativePath: "src/main.ts" }],
|
|
137
|
+
totalMatched: 2,
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
grep: vi.fn().mockReturnValue({
|
|
141
|
+
ok: true,
|
|
142
|
+
value: {
|
|
143
|
+
items: [{ relativePath: "src/index.ts", lineNumber: 42, lineContent: "const x = 1;" }],
|
|
144
|
+
totalMatched: 1,
|
|
145
|
+
nextCursor: null,
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
multiGrep: vi.fn().mockReturnValue({
|
|
149
|
+
ok: true,
|
|
150
|
+
value: {
|
|
151
|
+
items: [
|
|
152
|
+
{ relativePath: "src/index.ts", lineNumber: 10, lineContent: "import {foo}" },
|
|
153
|
+
{ relativePath: "src/main.ts", lineNumber: 5, lineContent: "const baz" },
|
|
154
|
+
],
|
|
155
|
+
totalMatched: 2,
|
|
156
|
+
nextCursor: null,
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
destroy: vi.fn(),
|
|
160
|
+
...overrides,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
describe("piPrettyExtension integration", () => {
|
|
165
|
+
let tools: Map<string, any>;
|
|
166
|
+
let events: Map<string, Function>;
|
|
167
|
+
let mockPi: any;
|
|
168
|
+
|
|
169
|
+
// SDK execute mocks
|
|
170
|
+
const findExec = vi.fn();
|
|
171
|
+
const grepExec = vi.fn();
|
|
172
|
+
const readExec = vi.fn();
|
|
173
|
+
const bashExec = vi.fn();
|
|
174
|
+
const lsExec = vi.fn();
|
|
175
|
+
|
|
176
|
+
function makeDeps(withFFF: boolean, finderOverrides?: Record<string, any>): PiPrettyDeps {
|
|
177
|
+
const finder = mkFinder(finderOverrides);
|
|
178
|
+
return {
|
|
179
|
+
sdk: {
|
|
180
|
+
createReadToolDefinition: mockToolFactory(readExec),
|
|
181
|
+
createBashToolDefinition: mockToolFactory(bashExec),
|
|
182
|
+
createLsToolDefinition: mockToolFactory(lsExec),
|
|
183
|
+
createFindToolDefinition: mockToolFactory(findExec),
|
|
184
|
+
createGrepToolDefinition: mockToolFactory(grepExec),
|
|
185
|
+
getAgentDir: () => "/tmp/pi-pretty-test",
|
|
186
|
+
},
|
|
187
|
+
TextComponent: class { private t = ""; setText(v: string) { this.t = v; } getText() { return this.t; } },
|
|
188
|
+
fffModule: withFFF
|
|
189
|
+
? { FileFinder: { create: vi.fn().mockReturnValue({ ok: true, value: finder }) } }
|
|
190
|
+
: undefined,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
tools = new Map();
|
|
196
|
+
events = new Map();
|
|
197
|
+
mockPi = {
|
|
198
|
+
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
|
|
199
|
+
registerCommand: vi.fn((c: any) => {}),
|
|
200
|
+
on: vi.fn((e: string, h: Function) => events.set(e, h)),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
for (const fn of [findExec, grepExec, readExec, bashExec, lsExec]) fn.mockReset();
|
|
204
|
+
findExec.mockResolvedValue({ content: [{ type: "text", text: "src/index.ts\nsrc/main.ts" }] });
|
|
205
|
+
grepExec.mockResolvedValue({ content: [{ type: "text", text: "src/index.ts:10:const x = 1;" }] });
|
|
206
|
+
readExec.mockResolvedValue({ content: [{ type: "text", text: "content" }] });
|
|
207
|
+
bashExec.mockResolvedValue({ content: [{ type: "text", text: "output" }] });
|
|
208
|
+
lsExec.mockResolvedValue({ content: [{ type: "text", text: "f1\nf2" }] });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
function load(withFFF = false, finderOverrides?: Record<string, any>) {
|
|
212
|
+
const deps = makeDeps(withFFF, finderOverrides);
|
|
213
|
+
piPrettyExtension(mockPi, deps);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function loadWithFFF(finderOverrides?: Record<string, any>) {
|
|
217
|
+
load(true, finderOverrides);
|
|
218
|
+
const start = events.get("session_start")!;
|
|
219
|
+
expect(start, "session_start not registered").toBeDefined();
|
|
220
|
+
await start({}, { cwd: "/tmp/test" });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- registration --------------------------------------------------
|
|
224
|
+
|
|
225
|
+
describe("tool registration", () => {
|
|
226
|
+
it("registers core tools (find, grep, read, bash, ls)", () => {
|
|
227
|
+
load();
|
|
228
|
+
for (const n of ["find", "grep", "read", "bash", "ls"]) {
|
|
229
|
+
expect(tools.has(n), `missing: ${n}`).toBe(true);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("registers multi_grep when FFF available", () => {
|
|
234
|
+
load(true);
|
|
235
|
+
expect(tools.has("multi_grep")).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("NO multi_grep when FFF unavailable", () => {
|
|
239
|
+
load(false);
|
|
240
|
+
expect(tools.has("multi_grep")).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("registers session_start + session_shutdown", () => {
|
|
244
|
+
load();
|
|
245
|
+
expect(events.has("session_start")).toBe(true);
|
|
246
|
+
expect(events.has("session_shutdown")).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ---- find: SDK fallback (no FFF) -----------------------------------
|
|
251
|
+
|
|
252
|
+
describe("find — SDK fallback", () => {
|
|
253
|
+
it("delegates to SDK when FFF not loaded", async () => {
|
|
254
|
+
load(false);
|
|
255
|
+
const r = await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
|
|
256
|
+
expect(findExec).toHaveBeenCalledOnce();
|
|
257
|
+
expect(r.details._type).toBe("findResult");
|
|
258
|
+
expect(r.details.pattern).toBe("*.ts");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("counts matches from SDK text", async () => {
|
|
262
|
+
findExec.mockResolvedValue({ content: [{ type: "text", text: "a.ts\nb.ts\nc.ts" }] });
|
|
263
|
+
load(false);
|
|
264
|
+
const r = await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
|
|
265
|
+
expect(r.details.matchCount).toBe(3);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ---- grep: SDK fallback (no FFF) -----------------------------------
|
|
270
|
+
|
|
271
|
+
describe("grep — SDK fallback", () => {
|
|
272
|
+
it("delegates to SDK when FFF not loaded", async () => {
|
|
273
|
+
load(false);
|
|
274
|
+
const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
|
|
275
|
+
expect(grepExec).toHaveBeenCalledOnce();
|
|
276
|
+
expect(r.details._type).toBe("grepResult");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("counts ripgrep-style matches", async () => {
|
|
280
|
+
grepExec.mockResolvedValue({
|
|
281
|
+
content: [{ type: "text", text: "a.ts:1:TODO\na.ts:5:TODO\nb.ts:10:TODO" }],
|
|
282
|
+
});
|
|
283
|
+
load(false);
|
|
284
|
+
const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
|
|
285
|
+
expect(r.details.matchCount).toBe(3);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---- find: FFF path ------------------------------------------------
|
|
290
|
+
|
|
291
|
+
describe("find — FFF path", () => {
|
|
292
|
+
it("uses FFF fileSearch when initialized", async () => {
|
|
293
|
+
await loadWithFFF();
|
|
294
|
+
const r = await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
|
|
295
|
+
expect(findExec).not.toHaveBeenCalled();
|
|
296
|
+
expect(r.details._type).toBe("findResult");
|
|
297
|
+
expect(r.content[0].text).toContain("src/index.ts");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("falls back to SDK on FFF { ok: false }", async () => {
|
|
301
|
+
await loadWithFFF({
|
|
302
|
+
fileSearch: vi.fn().mockReturnValue({ ok: false, error: "fail" }),
|
|
303
|
+
});
|
|
304
|
+
await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
|
|
305
|
+
expect(findExec).toHaveBeenCalledOnce();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("falls back to SDK on FFF throw", async () => {
|
|
309
|
+
await loadWithFFF({
|
|
310
|
+
fileSearch: vi.fn().mockImplementation(() => { throw new Error("crash"); }),
|
|
311
|
+
});
|
|
312
|
+
await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
|
|
313
|
+
expect(findExec).toHaveBeenCalledOnce();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("respects limit param", async () => {
|
|
317
|
+
const fileSearch = vi.fn().mockReturnValue({
|
|
318
|
+
ok: true,
|
|
319
|
+
value: { items: Array.from({ length: 50 }, (_, i) => ({ relativePath: `f${i}.ts` })), totalMatched: 50 },
|
|
320
|
+
});
|
|
321
|
+
await loadWithFFF({ fileSearch });
|
|
322
|
+
await tools.get("find")!.execute("t1", { pattern: "*.ts", limit: 5 }, null, null, {});
|
|
323
|
+
expect(fileSearch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ pageSize: 5 }));
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("includes path in search query", async () => {
|
|
327
|
+
const fileSearch = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0 } });
|
|
328
|
+
await loadWithFFF({ fileSearch });
|
|
329
|
+
await tools.get("find")!.execute("t1", { pattern: "*.ts", path: "src/" }, null, null, {});
|
|
330
|
+
expect(fileSearch).toHaveBeenCalledWith("src/ *.ts", expect.any(Object));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("shows partial-index + limit notices", async () => {
|
|
334
|
+
await loadWithFFF({
|
|
335
|
+
waitForScan: vi.fn().mockResolvedValue({ ok: true, value: false }),
|
|
336
|
+
fileSearch: vi.fn().mockReturnValue({
|
|
337
|
+
ok: true,
|
|
338
|
+
value: { items: Array.from({ length: 200 }, (_, i) => ({ relativePath: `f${i}` })), totalMatched: 500 },
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
const text = (await tools.get("find")!.execute("t1", { pattern: "*" }, null, null, {})).content[0].text;
|
|
342
|
+
expect(text).toContain("partial file index");
|
|
343
|
+
expect(text).toContain("200 limit reached");
|
|
344
|
+
expect(text).toContain("500 total matches");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ---- grep: FFF path ------------------------------------------------
|
|
349
|
+
|
|
350
|
+
describe("grep — FFF path", () => {
|
|
351
|
+
it("uses FFF grep when initialized", async () => {
|
|
352
|
+
await loadWithFFF();
|
|
353
|
+
const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
|
|
354
|
+
expect(grepExec).not.toHaveBeenCalled();
|
|
355
|
+
expect(r.content[0].text).toContain("src/index.ts:42:const x = 1;");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("literal=true → mode=plain", async () => {
|
|
359
|
+
const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
360
|
+
await loadWithFFF({ grep });
|
|
361
|
+
await tools.get("grep")!.execute("t1", { pattern: "foo", literal: true }, null, null, {});
|
|
362
|
+
expect(grep).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ mode: "plain" }));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("no literal → mode=regex", async () => {
|
|
366
|
+
const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
367
|
+
await loadWithFFF({ grep });
|
|
368
|
+
await tools.get("grep")!.execute("t1", { pattern: "foo.*bar" }, null, null, {});
|
|
369
|
+
expect(grep).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ mode: "regex" }));
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("glob prepended to query", async () => {
|
|
373
|
+
const grep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
374
|
+
await loadWithFFF({ grep });
|
|
375
|
+
await tools.get("grep")!.execute("t1", { pattern: "TODO", glob: "*.ts" }, null, null, {});
|
|
376
|
+
expect(grep).toHaveBeenCalledWith("*.ts TODO", expect.any(Object));
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("falls back to SDK on throw", async () => {
|
|
380
|
+
await loadWithFFF({ grep: vi.fn().mockImplementation(() => { throw new Error("crash"); }) });
|
|
381
|
+
const r = await tools.get("grep")!.execute("t1", { pattern: "TODO" }, null, null, {});
|
|
382
|
+
expect(grepExec).toHaveBeenCalledOnce();
|
|
383
|
+
expect(r.details._type).toBe("grepResult");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("cursor notice when nextCursor present", async () => {
|
|
387
|
+
await loadWithFFF({
|
|
388
|
+
grep: vi.fn().mockReturnValue({
|
|
389
|
+
ok: true,
|
|
390
|
+
value: { items: [{ relativePath: "a.ts", lineNumber: 1, lineContent: "hit" }], totalMatched: 1, nextCursor: { p: 2 } },
|
|
391
|
+
}),
|
|
392
|
+
});
|
|
393
|
+
const text = (await tools.get("grep")!.execute("t1", { pattern: "hit" }, null, null, {})).content[0].text;
|
|
394
|
+
expect(text).toContain("More results available");
|
|
395
|
+
expect(text).toMatch(/cursor="fff_c\d+"/);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ---- multi_grep (FFF only) -----------------------------------------
|
|
400
|
+
|
|
401
|
+
describe("multi_grep", () => {
|
|
402
|
+
it("error for empty patterns", async () => {
|
|
403
|
+
await loadWithFFF();
|
|
404
|
+
const r = await tools.get("multi_grep")!.execute("t1", { patterns: [] }, null, null, null);
|
|
405
|
+
expect(r.content[0].text).toContain("patterns array must have at least 1 element");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("error when FFF not initialized (no session_start)", async () => {
|
|
409
|
+
load(true);
|
|
410
|
+
const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["foo"] }, null, null, null);
|
|
411
|
+
expect(r.content[0].text).toContain("FFF not initialized");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("returns multiGrep results", async () => {
|
|
415
|
+
await loadWithFFF();
|
|
416
|
+
const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["foo", "bar"] }, null, null, null);
|
|
417
|
+
expect(r.details._type).toBe("grepResult");
|
|
418
|
+
expect(r.content[0].text).toContain("src/index.ts");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("aborted signal → Aborted", async () => {
|
|
422
|
+
await loadWithFFF();
|
|
423
|
+
const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["x"] }, { aborted: true }, null, null);
|
|
424
|
+
expect(r.content[0].text).toBe("Aborted");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("multiGrep failure → error text", async () => {
|
|
428
|
+
await loadWithFFF({
|
|
429
|
+
multiGrep: vi.fn().mockReturnValue({ ok: false, error: "compile failed" }),
|
|
430
|
+
});
|
|
431
|
+
const r = await tools.get("multi_grep")!.execute("t1", { patterns: ["[bad"] }, null, null, null);
|
|
432
|
+
expect(r.content[0].text).toContain("compile failed");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("passes constraints and context", async () => {
|
|
436
|
+
const multiGrep = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0, nextCursor: null } });
|
|
437
|
+
await loadWithFFF({ multiGrep });
|
|
438
|
+
await tools.get("multi_grep")!.execute("t1", { patterns: ["a", "b"], constraints: "*.ts", context: 2 }, null, null, null);
|
|
439
|
+
expect(multiGrep).toHaveBeenCalledWith(expect.objectContaining({
|
|
440
|
+
patterns: ["a", "b"], constraints: "*.ts", beforeContext: 2, afterContext: 2,
|
|
441
|
+
}));
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ---- session lifecycle ---------------------------------------------
|
|
446
|
+
|
|
447
|
+
describe("session lifecycle", () => {
|
|
448
|
+
it("shutdown → subsequent find falls back to SDK", async () => {
|
|
449
|
+
await loadWithFFF();
|
|
450
|
+
await events.get("session_shutdown")!();
|
|
451
|
+
await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
|
|
452
|
+
expect(findExec).toHaveBeenCalledOnce();
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import piPrettyExtension, { __imageInternals } from "../src/index.js";
|
|
4
|
+
|
|
5
|
+
const ENV_KEYS = [
|
|
6
|
+
"TMUX",
|
|
7
|
+
"TERM",
|
|
8
|
+
"TERM_PROGRAM",
|
|
9
|
+
"LC_TERMINAL",
|
|
10
|
+
"GHOSTTY_RESOURCES_DIR",
|
|
11
|
+
"KITTY_WINDOW_ID",
|
|
12
|
+
"KITTY_PID",
|
|
13
|
+
"WEZTERM_EXECUTABLE",
|
|
14
|
+
"WEZTERM_CONFIG_DIR",
|
|
15
|
+
"WEZTERM_CONFIG_FILE",
|
|
16
|
+
"COLORTERM",
|
|
17
|
+
"PRETTY_IMAGE_PROTOCOL",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
class MockText {
|
|
21
|
+
private text = "";
|
|
22
|
+
constructor(_text = "", _x = 0, _y = 0) {}
|
|
23
|
+
setText(value: string) {
|
|
24
|
+
this.text = value;
|
|
25
|
+
}
|
|
26
|
+
getText() {
|
|
27
|
+
return this.text;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mockToolFactory(exec: any) {
|
|
32
|
+
return (_cwd: string) => ({
|
|
33
|
+
name: "mock",
|
|
34
|
+
description: "mock",
|
|
35
|
+
parameters: { type: "object", properties: {} },
|
|
36
|
+
execute: exec,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadReadTool(readExec: any) {
|
|
41
|
+
const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
|
|
42
|
+
const tools = new Map<string, any>();
|
|
43
|
+
const pi = {
|
|
44
|
+
registerTool: (tool: any) => tools.set(tool.name, tool),
|
|
45
|
+
registerCommand: () => {},
|
|
46
|
+
on: () => {},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
piPrettyExtension(pi, {
|
|
50
|
+
sdk: {
|
|
51
|
+
createReadToolDefinition: mockToolFactory(readExec),
|
|
52
|
+
createBashToolDefinition: mockToolFactory(noopExec),
|
|
53
|
+
createLsToolDefinition: mockToolFactory(noopExec),
|
|
54
|
+
createFindToolDefinition: mockToolFactory(noopExec),
|
|
55
|
+
createGrepToolDefinition: mockToolFactory(noopExec),
|
|
56
|
+
getAgentDir: () => "/tmp/pi-pretty-test",
|
|
57
|
+
},
|
|
58
|
+
TextComponent: MockText,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return tools.get("read");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("image rendering terminal detection", () => {
|
|
65
|
+
const envSnapshot = new Map<string, string | undefined>();
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
for (const key of ENV_KEYS) {
|
|
69
|
+
envSnapshot.set(key, process.env[key]);
|
|
70
|
+
delete process.env[key];
|
|
71
|
+
}
|
|
72
|
+
__imageInternals.resetCachesForTests();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
for (const key of ENV_KEYS) {
|
|
77
|
+
const value = envSnapshot.get(key);
|
|
78
|
+
if (value === undefined) delete process.env[key];
|
|
79
|
+
else process.env[key] = value;
|
|
80
|
+
}
|
|
81
|
+
__imageInternals.resetCachesForTests();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("detects kitty protocol inside tmux via KITTY_WINDOW_ID", () => {
|
|
85
|
+
process.env.TMUX = "/tmp/tmux-1000/default,123,0";
|
|
86
|
+
process.env.TERM_PROGRAM = "tmux";
|
|
87
|
+
process.env.KITTY_WINDOW_ID = "1";
|
|
88
|
+
|
|
89
|
+
expect(__imageInternals.getOuterTerminal()).toBe("kitty");
|
|
90
|
+
expect(__imageInternals.detectImageProtocol()).toBe("kitty");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("detects wezterm protocol inside tmux via WEZTERM_EXECUTABLE", () => {
|
|
94
|
+
process.env.TMUX = "/tmp/tmux-1000/default,123,0";
|
|
95
|
+
process.env.TERM_PROGRAM = "tmux";
|
|
96
|
+
process.env.WEZTERM_EXECUTABLE = "/Applications/WezTerm.app/Contents/MacOS/wezterm";
|
|
97
|
+
|
|
98
|
+
expect(__imageInternals.getOuterTerminal()).toBe("WezTerm");
|
|
99
|
+
expect(__imageInternals.detectImageProtocol()).toBe("iterm2");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("falls back to tmux client term for outer terminal detection", () => {
|
|
103
|
+
process.env.TMUX = "/tmp/tmux-1000/default,123,0";
|
|
104
|
+
process.env.TERM_PROGRAM = "tmux";
|
|
105
|
+
__imageInternals.setTmuxClientTermOverrideForTests("xterm-kitty");
|
|
106
|
+
|
|
107
|
+
expect(__imageInternals.getOuterTerminal()).toBe("kitty");
|
|
108
|
+
expect(__imageInternals.detectImageProtocol()).toBe("kitty");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("reports warning when tmux allow-passthrough is off", () => {
|
|
112
|
+
process.env.TMUX = "/tmp/tmux-1000/default,123,0";
|
|
113
|
+
__imageInternals.setTmuxAllowPassthroughOverrideForTests(false);
|
|
114
|
+
|
|
115
|
+
expect(__imageInternals.getTmuxPassthroughWarning("kitty")).toContain("allow-passthrough is off");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does not warn when tmux allow-passthrough is enabled", () => {
|
|
119
|
+
process.env.TMUX = "/tmp/tmux-1000/default,123,0";
|
|
120
|
+
__imageInternals.setTmuxAllowPassthroughOverrideForTests(true);
|
|
121
|
+
|
|
122
|
+
expect(__imageInternals.getTmuxPassthroughWarning("kitty")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("renders explicit warning for read image when tmux passthrough is off", async () => {
|
|
126
|
+
process.env.TMUX = "/tmp/tmux-1000/default,123,0";
|
|
127
|
+
process.env.TERM_PROGRAM = "tmux";
|
|
128
|
+
process.env.KITTY_WINDOW_ID = "1";
|
|
129
|
+
__imageInternals.setTmuxAllowPassthroughOverrideForTests(false);
|
|
130
|
+
|
|
131
|
+
const readTool = loadReadTool(async () => ({
|
|
132
|
+
content: [{ type: "image", data: Buffer.from("fake").toString("base64"), mimeType: "image/png" }],
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
const result = await readTool.execute("t1", { path: "media/inline-image.png" }, null, null, {});
|
|
136
|
+
const rendered = readTool.renderResult(result, {}, {}, {
|
|
137
|
+
lastComponent: new MockText(),
|
|
138
|
+
isError: false,
|
|
139
|
+
state: {},
|
|
140
|
+
expanded: false,
|
|
141
|
+
invalidate: () => {},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(rendered.getText()).toContain("allow-passthrough is off");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("warns on non-PNG payloads for kitty protocol", async () => {
|
|
148
|
+
process.env.TERM_PROGRAM = "kitty";
|
|
149
|
+
|
|
150
|
+
const readTool = loadReadTool(async () => ({
|
|
151
|
+
content: [{ type: "image", data: Buffer.from("jpeg").toString("base64"), mimeType: "image/jpeg" }],
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
const result = await readTool.execute("t1", { path: "media/photo.jpg" }, null, null, {});
|
|
155
|
+
const rendered = readTool.renderResult(result, {}, {}, {
|
|
156
|
+
lastComponent: new MockText(),
|
|
157
|
+
isError: false,
|
|
158
|
+
state: {},
|
|
159
|
+
expanded: false,
|
|
160
|
+
invalidate: () => {},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(rendered.getText()).toContain("supports PNG payloads");
|
|
164
|
+
});
|
|
165
|
+
});
|