@heyhuynhgiabuu/pi-pretty 0.1.4 → 0.1.6

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 +118 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyhuynhgiabuu/pi-pretty",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
@@ -155,6 +155,80 @@ function lang(fp: string): BundledLanguage | undefined {
155
155
  return EXT_LANG[extname(fp).slice(1).toLowerCase()];
156
156
  }
157
157
 
158
+ // ---------------------------------------------------------------------------
159
+ // Terminal image rendering (iTerm2 / Kitty / Ghostty inline image protocols)
160
+ // ---------------------------------------------------------------------------
161
+
162
+ type ImageProtocol = "iterm2" | "kitty" | "none";
163
+
164
+ function detectImageProtocol(): ImageProtocol {
165
+ const term = process.env.TERM_PROGRAM ?? "";
166
+ // Ghostty and Kitty use the Kitty graphics protocol
167
+ if (term === "ghostty" || term === "kitty") return "kitty";
168
+ // iTerm2, WezTerm, Mintty support the iTerm2 protocol
169
+ if (["iTerm.app", "WezTerm", "mintty"].includes(term)) return "iterm2";
170
+ if (process.env.LC_TERMINAL === "iTerm2") return "iterm2";
171
+ return "none";
172
+ }
173
+
174
+ /**
175
+ * Render base64 image inline using iTerm2 inline image protocol.
176
+ * Protocol: ESC ] 1337 ; File=[args] : base64data BEL
177
+ */
178
+ function renderIterm2Image(
179
+ base64Data: string,
180
+ opts: { width?: string; name?: string } = {},
181
+ ): string {
182
+ const args: string[] = ["inline=1", "preserveAspectRatio=1"];
183
+ if (opts.width) args.push(`width=${opts.width}`);
184
+ if (opts.name) args.push(`name=${Buffer.from(opts.name).toString("base64")}`);
185
+ const byteSize = Math.ceil(base64Data.length * 3 / 4);
186
+ args.push(`size=${byteSize}`);
187
+ return `\x1b]1337;File=${args.join(";")}:${base64Data}\x07`;
188
+ }
189
+
190
+ /**
191
+ * Render base64 image inline using Kitty graphics protocol.
192
+ * Protocol: ESC _G <key>=<value>,...; <base64data> ESC \
193
+ * Chunked in 4096-byte pieces as required by protocol.
194
+ * Supported by: Kitty, Ghostty
195
+ */
196
+ function renderKittyImage(
197
+ base64Data: string,
198
+ opts: { cols?: number } = {},
199
+ ): string {
200
+ const chunks: string[] = [];
201
+ const CHUNK_SIZE = 4096;
202
+
203
+ for (let i = 0; i < base64Data.length; i += CHUNK_SIZE) {
204
+ const chunk = base64Data.slice(i, i + CHUNK_SIZE);
205
+ const isFirst = i === 0;
206
+ const isLast = i + CHUNK_SIZE >= base64Data.length;
207
+ const more = isLast ? 0 : 1;
208
+
209
+ if (isFirst) {
210
+ // First chunk: include all metadata
211
+ // a=T (transmit+display), f=100 (PNG), t=d (direct data)
212
+ const colPart = opts.cols ? `,c=${opts.cols}` : "";
213
+ chunks.push(`\x1b_Ga=T,f=100,t=d,m=${more}${colPart};${chunk}\x1b\\`);
214
+ } else {
215
+ // Continuation chunks
216
+ chunks.push(`\x1b_Gm=${more};${chunk}\x1b\\`);
217
+ }
218
+ }
219
+
220
+ return chunks.join("");
221
+ }
222
+
223
+ /**
224
+ * Get human-readable file size
225
+ */
226
+ function humanSize(bytes: number): string {
227
+ if (bytes < 1024) return `${bytes}B`;
228
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
229
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
230
+ }
231
+
158
232
  // ---------------------------------------------------------------------------
159
233
  // File-type icons — Nerd Font glyphs (Seti-UI + Devicons, stable in NF v3+)
160
234
  //
@@ -576,6 +650,18 @@ export default function piPrettyExtension(pi: any): void {
576
650
  const fp = params.path ?? "";
577
651
  const offset = params.offset ?? 1;
578
652
 
653
+ // Check for image content
654
+ const imageBlock = result.content?.find((c: any) => c.type === "image");
655
+ if (imageBlock) {
656
+ (result as any).details = {
657
+ _type: "readImage",
658
+ filePath: fp,
659
+ data: imageBlock.data,
660
+ mimeType: imageBlock.mimeType ?? "image/png",
661
+ };
662
+ return result;
663
+ }
664
+
579
665
  // Extract text content for rendering
580
666
  const textContent = result.content
581
667
  ?.filter((c: any) => c.type === "text")
@@ -620,6 +706,38 @@ export default function piPrettyExtension(pi: any): void {
620
706
  }
621
707
 
622
708
  const d = result.details;
709
+
710
+ // Image rendering
711
+ if (d?._type === "readImage") {
712
+ const tw = termW();
713
+ const out: string[] = [];
714
+ const fname = basename(d.filePath);
715
+ const byteSize = Math.ceil((d.data as string).length * 3 / 4);
716
+ const sizeStr = humanSize(byteSize);
717
+ const mimeStr = d.mimeType ?? "image";
718
+
719
+ out.push(` ${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`);
720
+ out.push(rule(tw));
721
+
722
+ const protocol = detectImageProtocol();
723
+ if (protocol === "kitty") {
724
+ const imgCols = Math.min(tw - 4, 80);
725
+ out.push(renderKittyImage(d.data, { cols: imgCols }));
726
+ } else if (protocol === "iterm2") {
727
+ const imgWidth = Math.min(tw - 4, 80);
728
+ out.push(renderIterm2Image(d.data, {
729
+ width: `${imgWidth}`,
730
+ name: fname,
731
+ }));
732
+ } else {
733
+ out.push(` ${FG_DIM}(Inline image preview requires Ghostty, iTerm2, WezTerm, or Kitty)${RST}`);
734
+ }
735
+
736
+ out.push(rule(tw));
737
+ text.setText(out.join("\n"));
738
+ return text;
739
+ }
740
+
623
741
  if (d?._type === "readFile" && d.content) {
624
742
  const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}`;
625
743
  if (ctx.state._rk !== key) {