aboutsummaryrefslogtreecommitdiffstats
path: root/pi-ext/sixel-images.ts
blob: d8a1ef2044f49955031c06cbe911840de6001ff0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
/**
 * sl0p Sixel Images (monkeypatch)
 *
 * pi renders inline images only via the Kitty or iTerm2 graphics protocols, and
 * hard-disables images under tmux/screen (ImageProtocol = "kitty" | "iterm2" |
 * null). This extension adds a third path — SIXEL — and forces it on even under
 * tmux, for terminals (like this one) whose tmux has sixel support.
 *
 * How:
 *   1. Force pi-tui's cached terminal capabilities to a custom `images: "sixel"`
 *      marker (setCapabilities), bypassing the tmux gate. All image code paths
 *      read getCapabilities() at render time, so they pick this up. The marker
 *      is not "kitty", so the tool-execution image loop stops filtering to PNG.
 *   2. Patch the pi-tui `Image` component's render to, when the marker is set,
 *      encode the image to sixel with `chafa` (cell-aware: `-s <cols>x<rows>`
 *      can't overflow the reserved rows) and lay it out iTerm2-style: reserve
 *      rows-1 blank lines then draw the sequence on the last line after moving
 *      the cursor up, so the TUI's line accounting stays correct.
 *
 * Notes:
 *   - Sixel bytes survive pi-tui's line pipeline: normalizeTerminalOutput only
 *     touches tabs/Thai-Lao, and components pre-fit their lines (no truncation).
 *   - `chafa --polite on` avoids terminal queries that could disturb the TUI.
 *   - Our slop-tool-boxes `read` override uses its own shell, so it renders image
 *     blocks itself (via this same patched Image) — see slop-tool-boxes.ts.
 *   - Requires `chafa` on PATH. If encoding fails we fall back to pi's native
 *     text placeholder.
 *
 * Reload-safe (re-wraps from a captured original) and restores on shutdown.
 */

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
  Image,
  getCapabilities,
  setCapabilities,
  getCellDimensions,
  getImageDimensions,
  calculateImageRows,
} from "@earendil-works/pi-tui";
import { execFileSync } from "child_process";

// Our capability marker. Truthy + not "kitty"/"iterm2", so pi treats images as
// enabled but its kitty/iterm2 encoders are skipped and ours takes over.
const SIXEL = "sixel";
const ORIG = Symbol.for("sl0p.sixel.imageRender");

// Force images on (bypassing the tmux/screen gate), preserving other caps.
function forceCaps(): void {
  const c = getCapabilities();
  setCapabilities({
    images: SIXEL as any,
    trueColor: c?.trueColor ?? true,
    hyperlinks: c?.hyperlinks ?? false,
  });
}

// Encode raw image bytes to a sixel sequence sized to fit cols×rows cells.
// chafa constrains to the box (never exceeds `rows`), so our reservation holds.
function encodeSixel(base64: string, cols: number, rows: number): string | null {
  try {
    const buf = Buffer.from(base64, "base64");
    const out = execFileSync(
      "chafa",
      ["-f", "sixel", "-s", `${cols}x${rows}`, "--animate", "off", "--polite", "on", "-"],
      { input: buf, maxBuffer: 64 << 20, timeout: 8000, stdio: ["pipe", "pipe", "ignore"] },
    );
    const s = out.toString("latin1");
    return s.includes("\x1bP") ? s : null; // DCS intro present → looks like sixel
  } catch {
    return null;
  }
}

// Render image bytes to framed body lines: reserve rows-1 blanks, then draw the
// sixel on the last line after moving the cursor up into the reserved block.
export function renderSixelLines(
  base64: string,
  mimeType: string,
  cols: number,
  dims?: { widthPx: number; heightPx: number },
): string[] | null {
  const d = dims || getImageDimensions(base64, mimeType) || { widthPx: 800, heightPx: 600 };
  const rows = Math.max(1, calculateImageRows(d as any, cols, getCellDimensions()));
  const seq = encodeSixel(base64, cols, rows);
  if (!seq) return null;
  const lines: string[] = [];
  for (let i = 0; i < rows - 1; i++) lines.push("");
  const moveUp = rows > 1 ? `\x1b[${rows - 1}A` : "";
  // Prefix a self-terminating empty Kitty APC (\x1b_G ... \x1b\\). pi's TUI has a
  // hard "line exceeds terminal width" guard that only skips lines its
  // isImageLine() recognizes (Kitty/iTerm2 prefixes). A raw sixel (\x1bP...) is
  // not recognized and would crash the render. This marker makes isImageLine()
  // true (so the width guard + reset-normalization + overlay compositing all skip
  // the line); real terminals ignore the empty APC. The Kitty header parser finds
  // no i=/r= params, so it treats the line as rows:1 with no image ids.
  const APC_MARK = "\x1b_G\x1b\\";
  lines.push(APC_MARK + moveUp + seq);
  return lines;
}

export default function (pi: ExtensionAPI) {
  forceCaps();
  pi.on("session_start", () => forceCaps());

  const proto: any = (Image as any)?.prototype;
  if (!proto || typeof proto.render !== "function") return;
  if (!(Image as any)[ORIG]) (Image as any)[ORIG] = proto.render;
  const orig = (Image as any)[ORIG] as (width: number) => string[];

  proto.render = function patchedRender(this: any, width: number): string[] {
    try {
      if (getCapabilities().images !== SIXEL) return orig.call(this, width);
      if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
      const cols = Math.max(1, Math.min(width - 2, this.options?.maxWidthCells ?? 60));
      const lines =
        renderSixelLines(this.base64Data, this.mimeType, cols, this.dimensions) ??
        orig.call(this, width); // fall back to pi's text placeholder
      this.cachedLines = lines;
      this.cachedWidth = width;
      return lines;
    } catch {
      return orig.call(this, width);
    }
  };

  pi.on("session_shutdown", () => {
    try {
      if ((Image as any)[ORIG]) proto.render = (Image as any)[ORIG];
    } catch {
      /* ignore */
    }
  });
}