@heyhuynhgiabuu/pi-pretty 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +91 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyhuynhgiabuu/pi-pretty",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",
package/src/index.ts CHANGED
@@ -156,39 +156,107 @@ function lang(fp: string): BundledLanguage | undefined {
156
156
  }
157
157
 
158
158
  // ---------------------------------------------------------------------------
159
- // Terminal image rendering (iTerm2 / Kitty / WezTerm inline image protocol)
159
+ // Terminal image rendering (iTerm2 / Kitty / Ghostty inline image protocols)
160
+ // Handles tmux passthrough for image protocols.
160
161
  // ---------------------------------------------------------------------------
161
162
 
162
- function supportsInlineImages(): boolean {
163
+ type ImageProtocol = "iterm2" | "kitty" | "none";
164
+
165
+ const IS_TMUX = !!process.env.TMUX;
166
+
167
+ /**
168
+ * Detect the outer terminal when running inside tmux.
169
+ * tmux sets TERM_PROGRAM=tmux, but the real terminal is often in
170
+ * the environment of the tmux server or can be inferred.
171
+ */
172
+ function getOuterTerminal(): string {
173
+ // Direct terminal (not in tmux)
163
174
  const term = process.env.TERM_PROGRAM ?? "";
175
+ if (term !== "tmux" && term !== "screen") return term;
176
+
177
+ // Inside tmux: check common env vars that leak through
178
+ // Ghostty sets this; iTerm2 sets LC_TERMINAL
179
+ if (process.env.LC_TERMINAL === "iTerm2") return "iTerm.app";
180
+
181
+ // TERM_PROGRAM_VERSION sometimes survives into tmux
182
+ // Try to detect via COLORTERM or other hints
183
+ if (process.env.GHOSTTY_RESOURCES_DIR) return "ghostty";
184
+
185
+ // Default: assume modern terminal if truecolor is supported
186
+ if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit") {
187
+ // Can't determine exact terminal, but likely modern
188
+ return "unknown-modern";
189
+ }
190
+ return term;
191
+ }
192
+
193
+ function detectImageProtocol(): ImageProtocol {
194
+ const term = getOuterTerminal();
195
+ // Ghostty and Kitty use the Kitty graphics protocol
196
+ if (term === "ghostty" || term === "kitty") return "kitty";
164
197
  // iTerm2, WezTerm, Mintty support the iTerm2 protocol
165
- if (["iTerm.app", "WezTerm", "mintty"].includes(term)) return true;
166
- // Kitty has its own protocol but also supports iTerm2 compat in newer versions
167
- if (term === "kitty") return true;
168
- // TERM_PROGRAM_VERSION for iTerm2 check
169
- if (process.env.LC_TERMINAL === "iTerm2") return true;
170
- return false;
198
+ if (["iTerm.app", "WezTerm", "mintty"].includes(term)) return "iterm2";
199
+ if (process.env.LC_TERMINAL === "iTerm2") return "iterm2";
200
+ return "none";
201
+ }
202
+
203
+ /**
204
+ * Wrap escape sequence for tmux passthrough.
205
+ * tmux requires: ESC Ptmux; <escaped-sequence> ESC \
206
+ * Inner ESC chars must be doubled.
207
+ */
208
+ function tmuxWrap(seq: string): string {
209
+ if (!IS_TMUX) return seq;
210
+ // Double all ESC chars inside the sequence
211
+ const escaped = seq.replace(/\x1b/g, "\x1b\x1b");
212
+ return `\x1bPtmux;${escaped}\x1b\\`;
171
213
  }
172
214
 
173
215
  /**
174
216
  * Render base64 image inline using iTerm2 inline image protocol.
175
217
  * Protocol: ESC ] 1337 ; File=[args] : base64data BEL
176
- * Supported by: iTerm2, WezTerm, Mintty, Kitty (compat mode)
177
218
  */
178
- function renderInlineImage(
219
+ function renderIterm2Image(
179
220
  base64Data: string,
180
- opts: { width?: string; height?: string; preserveAspect?: boolean; name?: string } = {},
221
+ opts: { width?: string; name?: string } = {},
181
222
  ): string {
182
- const args: string[] = ["inline=1"];
223
+ const args: string[] = ["inline=1", "preserveAspectRatio=1"];
183
224
  if (opts.width) args.push(`width=${opts.width}`);
184
- if (opts.height) args.push(`height=${opts.height}`);
185
- if (opts.preserveAspect !== false) args.push("preserveAspectRatio=1");
186
225
  if (opts.name) args.push(`name=${Buffer.from(opts.name).toString("base64")}`);
187
- // Calculate size for the size= param
188
226
  const byteSize = Math.ceil(base64Data.length * 3 / 4);
189
227
  args.push(`size=${byteSize}`);
228
+ const seq = `\x1b]1337;File=${args.join(";")}:${base64Data}\x07`;
229
+ return tmuxWrap(seq);
230
+ }
231
+
232
+ /**
233
+ * Render base64 image inline using Kitty graphics protocol.
234
+ * Protocol: ESC _G <key>=<value>,...; <base64data> ESC \
235
+ * Chunked in 4096-byte pieces as required by protocol.
236
+ * Supported by: Kitty, Ghostty
237
+ */
238
+ function renderKittyImage(
239
+ base64Data: string,
240
+ opts: { cols?: number } = {},
241
+ ): string {
242
+ const chunks: string[] = [];
243
+ const CHUNK_SIZE = 4096;
244
+
245
+ for (let i = 0; i < base64Data.length; i += CHUNK_SIZE) {
246
+ const chunk = base64Data.slice(i, i + CHUNK_SIZE);
247
+ const isFirst = i === 0;
248
+ const isLast = i + CHUNK_SIZE >= base64Data.length;
249
+ const more = isLast ? 0 : 1;
250
+
251
+ if (isFirst) {
252
+ const colPart = opts.cols ? `,c=${opts.cols}` : "";
253
+ chunks.push(tmuxWrap(`\x1b_Ga=T,f=100,t=d,m=${more}${colPart};${chunk}\x1b\\`));
254
+ } else {
255
+ chunks.push(tmuxWrap(`\x1b_Gm=${more};${chunk}\x1b\\`));
256
+ }
257
+ }
190
258
 
191
- return `\x1b]1337;File=${args.join(";")}:${base64Data}\x07`;
259
+ return chunks.join("");
192
260
  }
193
261
 
194
262
  /**
@@ -690,16 +758,18 @@ export default function piPrettyExtension(pi: any): void {
690
758
  out.push(` ${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`);
691
759
  out.push(rule(tw));
692
760
 
693
- if (supportsInlineImages()) {
694
- // Max width: use ~80 columns or terminal width, capped for readability
761
+ const protocol = detectImageProtocol();
762
+ if (protocol === "kitty") {
763
+ const imgCols = Math.min(tw - 4, 80);
764
+ out.push(renderKittyImage(d.data, { cols: imgCols }));
765
+ } else if (protocol === "iterm2") {
695
766
  const imgWidth = Math.min(tw - 4, 80);
696
- out.push(renderInlineImage(d.data, {
767
+ out.push(renderIterm2Image(d.data, {
697
768
  width: `${imgWidth}`,
698
769
  name: fname,
699
770
  }));
700
771
  } else {
701
- // Fallback: show metadata only
702
- out.push(` ${FG_DIM}(Inline image preview requires iTerm2, WezTerm, or Kitty)${RST}`);
772
+ out.push(` ${FG_DIM}(Inline image preview requires Ghostty, iTerm2, WezTerm, or Kitty)${RST}`);
703
773
  }
704
774
 
705
775
  out.push(rule(tw));