@heyhuynhgiabuu/pi-pretty 0.3.1 → 0.3.3

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,25 @@ 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`)
53
+
54
+ ## FFF data directory
55
+
56
+ When FFF is available, pi-pretty now stores its frecency/history data under a pi-pretty-specific path:
57
+
58
+ ```text
59
+ ~/.pi/agent/pi-pretty/fff/
60
+ ```
61
+
62
+ This makes it clear that the cache belongs to this extension rather than Pi core.
45
63
 
46
64
  ## Configuration
47
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyhuynhgiabuu/pi-pretty",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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,48 @@
1
+ # pi-pretty v0.3.3
2
+
3
+ ## Summary
4
+ This patch release combines the recent rendering and FFF-path fixes into a single `v0.3.3` release.
5
+
6
+ ## What changed
7
+ - Fixed themed tool background rendering so `toolSuccessBg` and `toolErrorBg` fill the full rendered width for core tool output.
8
+ - Preserved the active tool background across ANSI reset sequences to avoid broken background bands inside highlighted output.
9
+ - Kept error rendering on `theme.fg("error", ...)` while switching error panels to `toolErrorBg`.
10
+ - Moved pi-pretty FFF data from:
11
+ - `~/.pi/agent/fff`
12
+ - To:
13
+ - `~/.pi/agent/pi-pretty/fff`
14
+ - Added a helper to resolve the pi-pretty-specific FFF directory under `getAgentDir()`.
15
+ - Updated README to document the pi-pretty-owned FFF data location.
16
+ - Added a test that verifies the generated frecency/history DB paths use the new per-extension location.
17
+
18
+ ## Files
19
+ - `src/index.ts`
20
+ - `test/fff-integration.test.ts`
21
+ - `README.md`
22
+ - `package.json`
23
+ - `package-lock.json`
24
+
25
+ ## Verification
26
+ - `npm run typecheck` ✅
27
+ - `npm test` ✅ (47 tests)
28
+ - `npm run lint` ⚠️ still reports pre-existing Biome diagnostics in legacy code paths; no new release-blocking failure was introduced for this patch.
29
+
30
+ ## Upgrade notes
31
+ If your Pi theme defines:
32
+
33
+ ```json
34
+ {
35
+ "toolSuccessBg": "#1e2e1e",
36
+ "toolErrorBg": "#2e1e1e"
37
+ }
38
+ ```
39
+
40
+ core tool panels now render those backgrounds across the full line width instead of only behind printed characters.
41
+
42
+ When FFF is available, pi-pretty now stores frecency/history data under:
43
+
44
+ ```text
45
+ ~/.pi/agent/pi-pretty/fff/
46
+ ```
47
+
48
+ Existing data under `~/.pi/agent/fff` is not migrated automatically by this release.
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@
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
 
@@ -70,28 +71,41 @@ const FG_PURPLE = "\x1b[38;2;170;120;200m";
70
71
  const BG_STDERR = "\x1b[48;2;40;25;25m";
71
72
 
72
73
  const BG_DEFAULT = "\x1b[49m";
73
- let BG_BASE = BG_DEFAULT; // tool box base bg — updated from theme's toolSuccessBg
74
+ let BG_BASE = BG_DEFAULT; // tool box success/base bg — updated from theme's toolSuccessBg
75
+ let BG_ERROR = BG_DEFAULT; // tool box error bg — updated from theme's toolErrorBg
76
+
77
+ type BgTheme = { getBgAnsi?: (key: string) => string };
78
+ type FgTheme = { fg: (key: string, text: string) => string };
74
79
 
75
80
  /** Parse an ANSI 24-bit color escape into { r, g, b }. Handles both fg (38;2) and bg (48;2). */
76
81
  function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
77
- const m = ansi.match(/\x1b\[(?:38|48);2;(\d+);(\d+);(\d+)m/);
82
+ const m = ansi.match(new RegExp(`${ESC_RE}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
78
83
  return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
79
84
  }
80
85
 
81
- /** Read toolSuccessBg from the pi theme and update BG_BASE + RST.
86
+ function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
87
+ try {
88
+ const bgAnsi = theme.getBgAnsi?.(key);
89
+ return bgAnsi && parseAnsiRgb(bgAnsi) ? bgAnsi : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /** Read themed tool backgrounds and update BG_BASE / BG_ERROR + RST.
82
96
  * Call once when theme is first available. Idempotent. */
83
97
  let _bgBaseResolved = false;
84
- function resolveBaseBackground(theme: any): void {
98
+ function resolveBaseBackground(theme: BgTheme | null | undefined): void {
85
99
  if (_bgBaseResolved || !theme?.getBgAnsi) return;
86
100
  _bgBaseResolved = true;
87
- try {
88
- const bgAnsi = theme.getBgAnsi("toolSuccessBg");
89
- const parsed = parseAnsiRgb(bgAnsi);
90
- if (parsed) {
91
- BG_BASE = bgAnsi;
92
- RST = `\x1b[0m${BG_BASE}`;
93
- }
94
- } catch { /* ignore — keep defaults */ }
101
+
102
+ BG_BASE = getThemeBgAnsi(theme, "toolSuccessBg") ?? BG_DEFAULT;
103
+ BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
104
+ RST = `\x1b[0m${BG_BASE}`;
105
+ }
106
+
107
+ function renderToolError(error: string, theme: FgTheme): string {
108
+ return fillToolBackground(`\n${theme.fg("error", error)}`, BG_ERROR);
95
109
  }
96
110
 
97
111
  const ESC_RE = "\u001b";
@@ -125,6 +139,25 @@ function strip(s: string): string {
125
139
  return s.replace(ANSI_RE, "");
126
140
  }
127
141
 
142
+ function preserveToolBackground(ansi: string, bg: string): string {
143
+ return ansi.replace(ANSI_CAPTURE_RE, (seq, params: string) => {
144
+ const codes = params.split(";");
145
+ return params === "0" || codes.includes("49") ? `${seq}${bg}` : seq;
146
+ });
147
+ }
148
+
149
+ function fillToolBackground(text: string, bg = BG_BASE): string {
150
+ const width = termW();
151
+ return text
152
+ .split("\n")
153
+ .map((line) => {
154
+ const normalized = preserveToolBackground(line, bg);
155
+ const padding = Math.max(0, width - strip(normalized).length);
156
+ return `${bg}${normalized}${" ".repeat(padding)}${RST}`;
157
+ })
158
+ .join("\n");
159
+ }
160
+
128
161
  function termW(): number {
129
162
  const raw =
130
163
  process.stdout.columns || (process.stderr as any).columns || Number.parseInt(process.env.COLUMNS ?? "", 10) || 200;
@@ -217,7 +250,45 @@ function lang(fp: string): BundledLanguage | undefined {
217
250
 
218
251
  type ImageProtocol = "iterm2" | "kitty" | "none";
219
252
 
220
- const IS_TMUX = !!process.env.TMUX;
253
+ let _tmuxClientTermCache: string | null | undefined;
254
+ let _tmuxAllowPassthroughCache: boolean | null | undefined;
255
+ let _tmuxClientTermOverrideForTests: string | null | undefined;
256
+ let _tmuxAllowPassthroughOverrideForTests: boolean | null | undefined;
257
+
258
+ function isTmuxSession(): boolean {
259
+ return !!process.env.TMUX || /^(tmux|screen)/.test(process.env.TERM ?? "");
260
+ }
261
+
262
+ function normalizeTerminalName(term: string): string {
263
+ const t = term.toLowerCase();
264
+ if (t.includes("kitty")) return "kitty";
265
+ if (t.includes("ghostty")) return "ghostty";
266
+ if (t.includes("wezterm")) return "WezTerm";
267
+ if (t.includes("iterm")) return "iTerm.app";
268
+ if (t.includes("mintty")) return "mintty";
269
+ return term;
270
+ }
271
+
272
+ function readTmuxClientTerm(): string | null {
273
+ if (_tmuxClientTermOverrideForTests !== undefined) {
274
+ return _tmuxClientTermOverrideForTests ? normalizeTerminalName(_tmuxClientTermOverrideForTests) : null;
275
+ }
276
+ if (!isTmuxSession()) return null;
277
+ if (_tmuxClientTermCache !== undefined) return _tmuxClientTermCache;
278
+ try {
279
+ const term = childProcess
280
+ .execFileSync("tmux", ["display-message", "-p", "#{client_termname}"], {
281
+ encoding: "utf8",
282
+ stdio: ["ignore", "pipe", "ignore"],
283
+ timeout: 200,
284
+ })
285
+ .trim();
286
+ _tmuxClientTermCache = term ? normalizeTerminalName(term) : null;
287
+ } catch {
288
+ _tmuxClientTermCache = null;
289
+ }
290
+ return _tmuxClientTermCache;
291
+ }
221
292
 
222
293
  /**
223
294
  * Detect the outer terminal when running inside tmux.
@@ -225,27 +296,34 @@ const IS_TMUX = !!process.env.TMUX;
225
296
  * the environment of the tmux server or can be inferred.
226
297
  */
227
298
  function getOuterTerminal(): string {
228
- // Direct terminal (not in tmux)
229
- const term = process.env.TERM_PROGRAM ?? "";
230
- if (term !== "tmux" && term !== "screen") return term;
231
-
232
- // Inside tmux: check common env vars that leak through
233
- // Ghostty sets this; iTerm2 sets LC_TERMINAL
299
+ // Environment hints that often survive inside tmux
234
300
  if (process.env.LC_TERMINAL === "iTerm2") return "iTerm.app";
235
-
236
- // TERM_PROGRAM_VERSION sometimes survives into tmux
237
- // Try to detect via COLORTERM or other hints
238
301
  if (process.env.GHOSTTY_RESOURCES_DIR) return "ghostty";
302
+ if (process.env.KITTY_WINDOW_ID || process.env.KITTY_PID) return "kitty";
303
+ if (process.env.WEZTERM_EXECUTABLE || process.env.WEZTERM_CONFIG_DIR || process.env.WEZTERM_CONFIG_FILE) {
304
+ return "WezTerm";
305
+ }
239
306
 
240
- // Default: assume modern terminal if truecolor is supported
241
- if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit") {
242
- // Can't determine exact terminal, but likely modern
243
- return "unknown-modern";
307
+ const termProgram = process.env.TERM_PROGRAM ?? "";
308
+ if (termProgram && termProgram !== "tmux" && termProgram !== "screen") {
309
+ return normalizeTerminalName(termProgram);
244
310
  }
245
- return term;
311
+
312
+ const tmuxClientTerm = readTmuxClientTerm();
313
+ if (tmuxClientTerm) return tmuxClientTerm;
314
+
315
+ const term = process.env.TERM ?? "";
316
+ if (term) return normalizeTerminalName(term);
317
+ if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit") return "unknown-modern";
318
+ return termProgram;
246
319
  }
247
320
 
248
321
  function detectImageProtocol(): ImageProtocol {
322
+ const forced = (process.env.PRETTY_IMAGE_PROTOCOL ?? "").toLowerCase();
323
+ if (forced === "kitty" || forced === "iterm2" || forced === "none") {
324
+ return forced;
325
+ }
326
+
249
327
  const term = getOuterTerminal();
250
328
  // Ghostty and Kitty use the Kitty graphics protocol
251
329
  if (term === "ghostty" || term === "kitty") return "kitty";
@@ -255,18 +333,67 @@ function detectImageProtocol(): ImageProtocol {
255
333
  return "none";
256
334
  }
257
335
 
336
+ function tmuxAllowsPassthrough(): boolean | null {
337
+ if (_tmuxAllowPassthroughOverrideForTests !== undefined) return _tmuxAllowPassthroughOverrideForTests;
338
+ if (!isTmuxSession()) return null;
339
+ if (_tmuxAllowPassthroughCache !== undefined) return _tmuxAllowPassthroughCache;
340
+ try {
341
+ const value = childProcess
342
+ .execFileSync("tmux", ["show-options", "-gv", "allow-passthrough"], {
343
+ encoding: "utf8",
344
+ stdio: ["ignore", "pipe", "ignore"],
345
+ timeout: 200,
346
+ })
347
+ .trim()
348
+ .toLowerCase();
349
+ _tmuxAllowPassthroughCache = value === "on" || value === "all";
350
+ } catch {
351
+ _tmuxAllowPassthroughCache = null;
352
+ }
353
+ return _tmuxAllowPassthroughCache;
354
+ }
355
+
356
+ function getTmuxPassthroughWarning(protocol: ImageProtocol): string | null {
357
+ if (!isTmuxSession() || protocol === "none") return null;
358
+ if (tmuxAllowsPassthrough() === false) {
359
+ return "tmux allow-passthrough is off. Run: tmux set -g allow-passthrough on";
360
+ }
361
+ return null;
362
+ }
363
+
258
364
  /**
259
365
  * Wrap escape sequence for tmux passthrough.
260
366
  * tmux requires: ESC Ptmux; <escaped-sequence> ESC \
261
367
  * Inner ESC chars must be doubled.
262
368
  */
263
369
  function tmuxWrap(seq: string): string {
264
- if (!IS_TMUX) return seq;
370
+ if (!isTmuxSession()) return seq;
265
371
  // Double all ESC chars inside the sequence
266
372
  const escaped = seq.split("\x1b").join("\x1b\x1b");
267
373
  return `\x1bPtmux;${escaped}\x1b\\`;
268
374
  }
269
375
 
376
+ export const __imageInternals = {
377
+ isTmuxSession,
378
+ getOuterTerminal,
379
+ detectImageProtocol,
380
+ tmuxWrap,
381
+ tmuxAllowsPassthrough,
382
+ getTmuxPassthroughWarning,
383
+ setTmuxClientTermOverrideForTests: (value: string | null | undefined) => {
384
+ _tmuxClientTermOverrideForTests = value;
385
+ },
386
+ setTmuxAllowPassthroughOverrideForTests: (value: boolean | null | undefined) => {
387
+ _tmuxAllowPassthroughOverrideForTests = value;
388
+ },
389
+ resetCachesForTests: () => {
390
+ _tmuxClientTermCache = undefined;
391
+ _tmuxAllowPassthroughCache = undefined;
392
+ _tmuxClientTermOverrideForTests = undefined;
393
+ _tmuxAllowPassthroughOverrideForTests = undefined;
394
+ },
395
+ };
396
+
270
397
  /**
271
398
  * Render base64 image inline using iTerm2 inline image protocol.
272
399
  * Protocol: ESC ] 1337 ; File=[args] : base64data BEL
@@ -685,6 +812,10 @@ let _fffPartialIndex = false;
685
812
  let _fffDbDir: string | null = null;
686
813
  const FFF_SCAN_TIMEOUT = 15_000;
687
814
 
815
+ function getPiPrettyFffDir(agentDir: string): string {
816
+ return join(agentDir, "pi-pretty", "fff");
817
+ }
818
+
688
819
  async function fffEnsureFinder(cwd: string): Promise<any> {
689
820
  if (_fffFinder && !_fffFinder.isDestroyed) return _fffFinder;
690
821
  if (!_fffModule || !_fffDbDir) return null;
@@ -779,7 +910,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
779
910
  try {
780
911
  _fffModule = require("@ff-labs/fff-node");
781
912
  if (getAgentDir) {
782
- _fffDbDir = join(getAgentDir(), "fff");
913
+ _fffDbDir = getPiPrettyFffDir(getAgentDir());
783
914
  try {
784
915
  mkdirSync(_fffDbDir, { recursive: true });
785
916
  } catch {}
@@ -788,7 +919,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
788
919
  /* FFF not installed — SDK tools will be used */
789
920
  }
790
921
  } else if (_fffModule && getAgentDir) {
791
- _fffDbDir = join(getAgentDir(), "fff");
922
+ _fffDbDir = getPiPrettyFffDir(getAgentDir());
792
923
  try {
793
924
  mkdirSync(_fffDbDir, { recursive: true });
794
925
  } catch {}
@@ -806,7 +937,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
806
937
 
807
938
  if (!_fffDbDir) {
808
939
  const agentDir = getAgentDir?.() ?? join(home, ".pi/agent");
809
- _fffDbDir = join(agentDir, "fff");
940
+ _fffDbDir = getPiPrettyFffDir(agentDir);
810
941
  try {
811
942
  mkdirSync(_fffDbDir, { recursive: true });
812
943
  } catch {}
@@ -883,7 +1014,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
883
1014
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
884
1015
  const offset = args?.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
885
1016
  const limit = args?.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
886
- text.setText(`${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`);
1017
+ text.setText(fillToolBackground(`${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`));
887
1018
  return text;
888
1019
  },
889
1020
 
@@ -897,7 +1028,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
897
1028
  ?.filter((c: any) => c.type === "text")
898
1029
  .map((c: any) => c.text || "")
899
1030
  .join("\n") ?? "Error";
900
- text.setText(`\n${theme.fg("error", e)}`);
1031
+ text.setText(renderToolError(e, theme));
901
1032
  return text;
902
1033
  }
903
1034
 
@@ -916,9 +1047,16 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
916
1047
  out.push(rule(tw));
917
1048
 
918
1049
  const protocol = detectImageProtocol();
919
- if (protocol === "kitty") {
920
- const imgCols = Math.min(tw - 4, 80);
921
- out.push(renderKittyImage(d.data, { cols: imgCols }));
1050
+ const passthroughWarning = getTmuxPassthroughWarning(protocol);
1051
+ if (passthroughWarning) {
1052
+ out.push(` ${FG_YELLOW}${passthroughWarning}${RST}`);
1053
+ } else if (protocol === "kitty") {
1054
+ if (d.mimeType && d.mimeType !== "image/png") {
1055
+ out.push(` ${FG_YELLOW}Kitty/Ghostty inline preview currently supports PNG payloads (got ${d.mimeType})${RST}`);
1056
+ } else {
1057
+ const imgCols = Math.min(tw - 4, 80);
1058
+ out.push(renderKittyImage(d.data, { cols: imgCols }));
1059
+ }
922
1060
  } else if (protocol === "iterm2") {
923
1061
  const imgWidth = Math.min(tw - 4, 80);
924
1062
  out.push(
@@ -932,7 +1070,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
932
1070
  }
933
1071
 
934
1072
  out.push(rule(tw));
935
- text.setText(out.join("\n"));
1073
+ text.setText(fillToolBackground(out.join("\n")));
936
1074
  return text;
937
1075
  }
938
1076
 
@@ -941,24 +1079,24 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
941
1079
  if (ctx.state._rk !== key) {
942
1080
  ctx.state._rk = key;
943
1081
  const info = `${FG_DIM}${d.lineCount} lines${RST}`;
944
- ctx.state._rt = ` ${info}`;
1082
+ ctx.state._rt = fillToolBackground(` ${info}`);
945
1083
 
946
1084
  const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
947
1085
  renderFileContent(d.content, d.filePath, d.offset, maxShow)
948
1086
  .then((rendered: string) => {
949
1087
  if (ctx.state._rk !== key) return;
950
- ctx.state._rt = ` ${info}\n${rendered}`;
1088
+ ctx.state._rt = fillToolBackground(` ${info}\n${rendered}`);
951
1089
  ctx.invalidate();
952
1090
  })
953
1091
  .catch(() => {});
954
1092
  }
955
- text.setText(ctx.state._rt ?? ` ${FG_DIM}${d.lineCount} lines${RST}`);
1093
+ text.setText(ctx.state._rt ?? fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}`));
956
1094
  return text;
957
1095
  }
958
1096
 
959
1097
  // Fallback
960
1098
  const fallback = result.content?.[0]?.text ?? "read";
961
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1099
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallback).slice(0, 120))}`));
962
1100
  return text;
963
1101
  },
964
1102
  });
@@ -1009,7 +1147,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1009
1147
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1010
1148
  const timeout = args?.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
1011
1149
  text.setText(
1012
- `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", cmd.length > 80 ? cmd.slice(0, 77) + "…" : cmd)}${timeout}`,
1150
+ fillToolBackground(`${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", cmd.length > 80 ? cmd.slice(0, 77) + "…" : cmd)}${timeout}`),
1013
1151
  );
1014
1152
  return text;
1015
1153
  },
@@ -1024,7 +1162,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1024
1162
  ?.filter((c: any) => c.type === "text")
1025
1163
  .map((c: any) => c.text || "")
1026
1164
  .join("\n") ?? "Error";
1027
- text.setText(`\n${theme.fg("error", e)}`);
1165
+ text.setText(renderToolError(e, theme));
1028
1166
  return text;
1029
1167
  }
1030
1168
 
@@ -1049,15 +1187,15 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1049
1187
  if (lineCount > maxShow) {
1050
1188
  out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
1051
1189
  }
1052
- text.setText(out.join("\n"));
1190
+ text.setText(fillToolBackground(out.join("\n")));
1053
1191
  } else {
1054
- text.setText(header);
1192
+ text.setText(fillToolBackground(header));
1055
1193
  }
1056
1194
  return text;
1057
1195
  }
1058
1196
 
1059
1197
  const fallback = result.content?.[0]?.text ?? "done";
1060
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1198
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallback).slice(0, 120))}`));
1061
1199
  return text;
1062
1200
  },
1063
1201
  });
@@ -1099,7 +1237,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1099
1237
  resolveBaseBackground(theme);
1100
1238
  const fp = args?.path ?? ".";
1101
1239
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1102
- text.setText(`${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`);
1240
+ text.setText(fillToolBackground(`${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`));
1103
1241
  return text;
1104
1242
  },
1105
1243
 
@@ -1113,7 +1251,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1113
1251
  ?.filter((c: any) => c.type === "text")
1114
1252
  .map((c: any) => c.text || "")
1115
1253
  .join("\n") ?? "Error";
1116
- text.setText(`\n${theme.fg("error", e)}`);
1254
+ text.setText(renderToolError(e, theme));
1117
1255
  return text;
1118
1256
  }
1119
1257
 
@@ -1121,12 +1259,12 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1121
1259
  if (d?._type === "lsResult" && d.text) {
1122
1260
  const tree = renderTree(d.text, d.path);
1123
1261
  const info = `${FG_DIM}${d.entryCount} entries${RST}`;
1124
- text.setText(` ${info}\n${tree}`);
1262
+ text.setText(fillToolBackground(` ${info}\n${tree}`));
1125
1263
  return text;
1126
1264
  }
1127
1265
 
1128
1266
  const fallback = result.content?.[0]?.text ?? "listed";
1129
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1267
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallback).slice(0, 120))}`));
1130
1268
  return text;
1131
1269
  },
1132
1270
  });
@@ -1205,7 +1343,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1205
1343
  const pattern = args?.pattern ?? "";
1206
1344
  const path = args?.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1207
1345
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1208
- text.setText(`${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`);
1346
+ text.setText(fillToolBackground(`${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`));
1209
1347
  return text;
1210
1348
  },
1211
1349
 
@@ -1219,7 +1357,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1219
1357
  ?.filter((c: any) => c.type === "text")
1220
1358
  .map((c: any) => c.text || "")
1221
1359
  .join("\n") ?? "Error";
1222
- text.setText(`\n${theme.fg("error", e)}`);
1360
+ text.setText(renderToolError(e, theme));
1223
1361
  return text;
1224
1362
  }
1225
1363
 
@@ -1227,12 +1365,12 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1227
1365
  if (d?._type === "findResult" && d.text) {
1228
1366
  const rendered = renderFindResults(d.text);
1229
1367
  const info = `${FG_DIM}${d.matchCount} files${RST}`;
1230
- text.setText(` ${info}\n${rendered}`);
1368
+ text.setText(fillToolBackground(` ${info}\n${rendered}`));
1231
1369
  return text;
1232
1370
  }
1233
1371
 
1234
1372
  const fallback = result.content?.[0]?.text ?? "found";
1235
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1373
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallback).slice(0, 120))}`));
1236
1374
  return text;
1237
1375
  },
1238
1376
  });
@@ -1332,7 +1470,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1332
1470
  const path = args?.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1333
1471
  const glob = args?.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
1334
1472
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1335
- text.setText(`${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`);
1473
+ text.setText(fillToolBackground(`${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`));
1336
1474
  return text;
1337
1475
  },
1338
1476
 
@@ -1346,7 +1484,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1346
1484
  ?.filter((c: any) => c.type === "text")
1347
1485
  .map((c: any) => c.text || "")
1348
1486
  .join("\n") ?? "Error";
1349
- text.setText(`\n${theme.fg("error", e)}`);
1487
+ text.setText(renderToolError(e, theme));
1350
1488
  return text;
1351
1489
  }
1352
1490
 
@@ -1356,22 +1494,22 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1356
1494
  if (ctx.state._gk !== key) {
1357
1495
  ctx.state._gk = key;
1358
1496
  const info = `${FG_DIM}${d.matchCount} matches${RST}`;
1359
- ctx.state._gt = ` ${info}`;
1497
+ ctx.state._gt = fillToolBackground(` ${info}`);
1360
1498
 
1361
1499
  renderGrepResults(d.text, d.pattern)
1362
1500
  .then((rendered: string) => {
1363
1501
  if (ctx.state._gk !== key) return;
1364
- ctx.state._gt = ` ${info}\n${rendered}`;
1502
+ ctx.state._gt = fillToolBackground(` ${info}\n${rendered}`);
1365
1503
  ctx.invalidate();
1366
1504
  })
1367
1505
  .catch(() => {});
1368
1506
  }
1369
- text.setText(ctx.state._gt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`);
1507
+ text.setText(ctx.state._gt ?? fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}`));
1370
1508
  return text;
1371
1509
  }
1372
1510
 
1373
1511
  const fallback = result.content?.[0]?.text ?? "searched";
1374
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1512
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallback).slice(0, 120))}`));
1375
1513
  return text;
1376
1514
  },
1377
1515
  });
@@ -175,6 +175,9 @@ describe("piPrettyExtension integration", () => {
175
175
 
176
176
  function makeDeps(withFFF: boolean, finderOverrides?: Record<string, any>): PiPrettyDeps {
177
177
  const finder = mkFinder(finderOverrides);
178
+ const fffModule = finderOverrides?.FileFinder
179
+ ? { FileFinder: finderOverrides.FileFinder }
180
+ : { FileFinder: { create: vi.fn().mockReturnValue({ ok: true, value: finder }) } };
178
181
  return {
179
182
  sdk: {
180
183
  createReadToolDefinition: mockToolFactory(readExec),
@@ -185,9 +188,7 @@ describe("piPrettyExtension integration", () => {
185
188
  getAgentDir: () => "/tmp/pi-pretty-test",
186
189
  },
187
190
  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
+ fffModule: withFFF ? fffModule : undefined,
191
192
  };
192
193
  }
193
194
 
@@ -445,6 +446,18 @@ describe("piPrettyExtension integration", () => {
445
446
  // ---- session lifecycle ---------------------------------------------
446
447
 
447
448
  describe("session lifecycle", () => {
449
+ it("stores FFF data under a pi-pretty-specific directory", async () => {
450
+ const create = vi.fn().mockReturnValue({ ok: true, value: mkFinder() });
451
+ load(true, { FileFinder: { create } });
452
+ const start = events.get("session_start")!;
453
+ expect(start, "session_start not registered").toBeDefined();
454
+ await start({}, { cwd: "/tmp/test" });
455
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({
456
+ frecencyDbPath: "/tmp/pi-pretty-test/pi-pretty/fff/frecency.mdb",
457
+ historyDbPath: "/tmp/pi-pretty-test/pi-pretty/fff/history.mdb",
458
+ }));
459
+ });
460
+
448
461
  it("shutdown → subsequent find falls back to SDK", async () => {
449
462
  await loadWithFFF();
450
463
  await events.get("session_shutdown")!();
@@ -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
+ });