@heyhuynhgiabuu/pi-pretty 0.3.1 → 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.1",
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
+ ```
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
 
@@ -217,7 +218,45 @@ function lang(fp: string): BundledLanguage | undefined {
217
218
 
218
219
  type ImageProtocol = "iterm2" | "kitty" | "none";
219
220
 
220
- 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
+ }
221
260
 
222
261
  /**
223
262
  * Detect the outer terminal when running inside tmux.
@@ -225,27 +264,34 @@ const IS_TMUX = !!process.env.TMUX;
225
264
  * the environment of the tmux server or can be inferred.
226
265
  */
227
266
  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
267
+ // Environment hints that often survive inside tmux
234
268
  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
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
+ }
239
274
 
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";
275
+ const termProgram = process.env.TERM_PROGRAM ?? "";
276
+ if (termProgram && termProgram !== "tmux" && termProgram !== "screen") {
277
+ return normalizeTerminalName(termProgram);
244
278
  }
245
- 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;
246
287
  }
247
288
 
248
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
+
249
295
  const term = getOuterTerminal();
250
296
  // Ghostty and Kitty use the Kitty graphics protocol
251
297
  if (term === "ghostty" || term === "kitty") return "kitty";
@@ -255,18 +301,67 @@ function detectImageProtocol(): ImageProtocol {
255
301
  return "none";
256
302
  }
257
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
+
258
332
  /**
259
333
  * Wrap escape sequence for tmux passthrough.
260
334
  * tmux requires: ESC Ptmux; <escaped-sequence> ESC \
261
335
  * Inner ESC chars must be doubled.
262
336
  */
263
337
  function tmuxWrap(seq: string): string {
264
- if (!IS_TMUX) return seq;
338
+ if (!isTmuxSession()) return seq;
265
339
  // Double all ESC chars inside the sequence
266
340
  const escaped = seq.split("\x1b").join("\x1b\x1b");
267
341
  return `\x1bPtmux;${escaped}\x1b\\`;
268
342
  }
269
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
+
270
365
  /**
271
366
  * Render base64 image inline using iTerm2 inline image protocol.
272
367
  * Protocol: ESC ] 1337 ; File=[args] : base64data BEL
@@ -916,9 +1011,16 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
916
1011
  out.push(rule(tw));
917
1012
 
918
1013
  const protocol = detectImageProtocol();
919
- if (protocol === "kitty") {
920
- const imgCols = Math.min(tw - 4, 80);
921
- 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
+ }
922
1024
  } else if (protocol === "iterm2") {
923
1025
  const imgWidth = Math.min(tw - 4, 80);
924
1026
  out.push(
@@ -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
+ });