@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 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 so inline image protocols still work.
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.0",
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
- const IS_TMUX = !!process.env.TMUX;
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
- // Direct terminal (not in tmux)
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
- // Default: assume modern terminal if truecolor is supported
239
- if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit") {
240
- // Can't determine exact terminal, but likely modern
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
- return term;
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 (!IS_TMUX) return seq;
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
- export default function piPrettyExtension(pi: any): void {
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
- try {
784
- sdk = require("@mariozechner/pi-coding-agent");
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 = require("@mariozechner/pi-tui").Text;
791
- } catch {
792
- return;
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
- try {
806
- _fffModule = require("@ff-labs/fff-node");
807
- if (getAgentDir) {
808
- _fffDbDir = join(getAgentDir(), "fff");
809
- try {
810
- mkdirSync(_fffDbDir, { recursive: true });
811
- } catch {}
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
- } catch {
814
- /* FFF not installed — SDK tools will be used */
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
- if (protocol === "kitty") {
940
- const imgCols = Math.min(tw - 4, 80);
941
- out.push(renderKittyImage(d.data, { cols: imgCols }));
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
+ });