pi-orca-images 0.2.2 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Open image previews: each inline image gets an **Open image** link, and `/orca-images open` opens the latest image in macOS Preview. Previews are written to temporary files. Thanks to [@Ky1ezer0](https://github.com/Ky1ezer0) (#1).
6
+
7
+ ## 0.2.3
8
+
9
+ - Fix: Pi crashed with "This extension ctx is stale after session replacement or reload" when an `[Image N]` label was in the prompt after `/resume`, `/new`, `/fork` or `/reload`. Pi hands extensions a Proxy over the TUI that ignores `delete`, so the old session's render hook was never removed and kept styling labels with its stale ctx.
10
+
3
11
  ## 0.2.2
4
12
 
5
13
  - Gallery: preview image (`pi.image`) and shorter description for the pi.dev package page.
package/README.md CHANGED
@@ -43,21 +43,32 @@ Paste an image (or drop an image file) into Pi's prompt and the path is shown as
43
43
 
44
44
  It recognizes pastes made only of existing image files (`png`, `jpg`, `gif`, `webp`, `bmp`, `tiff`, `heic`, `avif`): plain paths, `~/` paths, quoted or backslash-escaped paths, and `file://` URLs. Text that mixes a path with other words is left alone. This works in every terminal, not only Orca, including Pi's own clipboard paste. Set `PI_IMAGE_LABELS=0` to turn it off.
45
45
 
46
+ ## Open image previews
47
+
48
+ Each rendered image gets an **Open image** file link beneath it. **Cmd-click** the link on macOS to open the preview through your terminal. In our Orca test, this opens an Orca image tab with zoom controls. Direct image clicks and hover-to-zoom are not supported.
49
+
50
+ On macOS, `/orca-images open` opens the last image rendered by Pi in **Preview**, using `open -a Preview`.
51
+
52
+ Preview copies are saved in a private `pi-orca-images-*` directory under the OS temporary directory, never in the project. Identical images reuse a file within each loaded extension instance. Files survive `/reload` and are deleted on normal Pi process exit; links expire when their files are removed. Crashes or forced termination can leave files behind, and long-running sessions can still accumulate previews. Older `.pi/image-previews/` folders are not automatically removed.
53
+
54
+ The saved bytes are those available to Pi and may already have been resized by a tool. Previews require automatic Orca detection and a writable temporary directory. They are inactive when `PI_ORCA_IMAGES=0` or an explicit `PI_IMAGE_PROTOCOL` overrides detection. The link and Preview command were tested with Pi 1.5.2 on macOS; opening through the command requires macOS Preview.
55
+
46
56
  ## Commands
47
57
 
48
58
  | Command | Effect |
49
59
  |---|---|
50
60
  | `/orca-images` or `/orca-images status` | Shows the detection decision, Pi's active image protocol, and whether the repaint fix and image labels are on |
51
61
  | `/orca-images test` | Draws a test gradient (it is not sent to the model) |
62
+ | `/orca-images open` | Opens the last rendered image in macOS Preview |
52
63
  | `/orca-images redraw` | Re-sends every visible image right now |
53
64
 
54
65
  ## Configuration
55
66
 
56
- It does nothing outside Orca. Your explicit choices always win:
67
+ The rendering fixes and preview links activate only in Orca; pasted image labels can work in any terminal. Your explicit choices always win:
57
68
 
58
69
  | Setting | Effect |
59
70
  |---|---|
60
- | `PI_ORCA_IMAGES=0` | Disables the Orca image fixes |
71
+ | `PI_ORCA_IMAGES=0` | Disables the Orca image fixes and preview feature |
61
72
  | `PI_IMAGE_LABELS=0` | Disables pasted image labels |
62
73
  | `PI_IMAGE_PROTOCOL=…` | Used as is; the package does not override it |
63
74
  | `terminal.images` in Pi settings | Applied by Pi on top of the package's choice |
@@ -69,6 +80,7 @@ It does nothing outside Orca. Your explicit choices always win:
69
80
  - Pi 1.4 in its default `fullscreen` TUI mode. In `regular` mode detection still works, but the repaint fix and tab recovery do not apply.
70
81
  - Not inside tmux or screen: image escapes do not reach Orca through them.
71
82
  - Image labels wrap internal methods of Pi's editor (`handlePaste`, `segment`, `expandPasteMarkers`, `render`). If a Pi release changes them, pasted paths simply stay as paths.
83
+ - Preview links read internal fields of Pi's image component. If those fields change or preview copies cannot be saved, links are omitted.
72
84
  - The repaint fix wraps two internal methods of Pi's TUI. If a Pi release renames them, `/orca-images status` reports the fix as `unavailable` and images fall back to the first-row behavior; detection keeps working.
73
85
 
74
86
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orca-images",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Inline images for Pi inside Orca's terminal, plus [Image N] paste labels.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/commands.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const SUBCOMMANDS = ["status", "test", "redraw"] as const;
1
+ export const SUBCOMMANDS = ["status", "test", "redraw", "open"] as const;
2
2
 
3
3
  /**
4
4
  * Argument completions for /orca-images. A complete subcommand yields none, so the first
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { completeSubcommand, SUBCOMMANDS } from "./commands.ts";
4
4
  import { decide, type Decision, labelsEnabled, OWNED_ENV } from "./detect.ts";
5
5
  import { type EditorWatcher, watchEditors } from "./labels.ts";
6
6
  import { installShim, type Shim, type ShimTarget } from "./shim.ts";
7
+ import { installPreviews } from "./preview.ts";
7
8
  import { formatStatus } from "./status.ts";
8
9
  import { testImagePng } from "./test-image.ts";
9
10
 
@@ -32,8 +33,13 @@ export default function piOrcaImages(pi: ExtensionAPI) {
32
33
  const labels = labelsEnabled(process.env);
33
34
  let shim: Shim | undefined;
34
35
  let watcher: EditorWatcher | undefined;
36
+ let previews: ReturnType<typeof installPreviews> | undefined;
35
37
 
36
38
  pi.on("session_start", async (_event, ctx) => {
39
+ if (ctx.hasUI && decision.action === "apply") {
40
+ previews?.uninstall();
41
+ previews = installPreviews({ onError: (message) => ctx.ui.notify(message, "error") });
42
+ }
37
43
  const repaint = decision.action === "apply" && decision.protocol === "kitty";
38
44
  if (!ctx.hasUI || (!repaint && !labels)) return;
39
45
  // The widget factory is the only public hook that hands an extension the TUI instance.
@@ -57,6 +63,8 @@ export default function piOrcaImages(pi: ExtensionAPI) {
57
63
  });
58
64
 
59
65
  pi.on("session_shutdown", async (event) => {
66
+ previews?.uninstall();
67
+ previews = undefined;
60
68
  // The watcher styles labels through this session's ctx, which goes stale on replacement.
61
69
  watcher?.uninstall();
62
70
  watcher = undefined;
@@ -80,7 +88,7 @@ export default function piOrcaImages(pi: ExtensionAPI) {
80
88
  });
81
89
 
82
90
  pi.registerCommand(COMMAND, {
83
- description: "Inline images in Orca: status, test, or redraw",
91
+ description: "Inline images in Orca: status, test, redraw, or open",
84
92
  getArgumentCompletions: completeSubcommand,
85
93
  handler: async (args, ctx) => {
86
94
  const sub = (args ?? "").trim() || "status";
@@ -97,6 +105,9 @@ export default function piOrcaImages(pi: ExtensionAPI) {
97
105
  );
98
106
  } else if (sub === "test") {
99
107
  pi.appendEntry(TEST_ENTRY, { width: 240, height: 120 });
108
+ } else if (sub === "open") {
109
+ if (previews) await previews.openLatest();
110
+ else ctx.ui.notify("Image previews are not active in this terminal.", "warning");
100
111
  } else if (sub === "redraw") {
101
112
  if (shim) shim.recover();
102
113
  else ctx.ui.notify("pi-orca-images is not active in this terminal; nothing to redraw.", "warning");
package/src/labels.ts CHANGED
@@ -189,18 +189,24 @@ export interface EditorWatcher {
189
189
  /**
190
190
  * Label every editor in the TUI tree, checked before each render. Pi and extensions can swap the
191
191
  * editor at any time (custom editors, settings changes), so a one-time lookup is not enough.
192
+ *
193
+ * Pi hands extensions a Proxy over the TUI whose own-property checks and deletes never reach the
194
+ * TUI, so uninstall restores by assignment and the wrapper turns inert even if something else
195
+ * still holds it: a leftover wrapper would relabel editors with a stale session's style.
192
196
  */
193
197
  export function watchEditors(
194
198
  tui: { doRender?: (...args: unknown[]) => unknown },
195
199
  options: LabelOptions,
196
200
  ): EditorWatcher {
197
201
  const installed = new Map<LabelTarget, ImageLabels>();
198
- const hadOwn = Object.hasOwn(tui, "doRender");
199
202
  const originalRender = tui.doRender;
203
+ let active = true;
200
204
  if (originalRender) {
201
205
  tui.doRender = function (this: unknown, ...args: unknown[]) {
202
- for (const editor of findEditors(tui)) {
203
- if (!installed.has(editor)) installed.set(editor, installImageLabels(editor, options));
206
+ if (active) {
207
+ for (const editor of findEditors(tui)) {
208
+ if (!installed.has(editor)) installed.set(editor, installImageLabels(editor, options));
209
+ }
204
210
  }
205
211
  return originalRender.apply(this, args);
206
212
  };
@@ -210,11 +216,10 @@ export function watchEditors(
210
216
  return installed.size;
211
217
  },
212
218
  uninstall() {
219
+ active = false;
213
220
  for (const labels of installed.values()) labels.uninstall();
214
221
  installed.clear();
215
- if (!originalRender) return;
216
- if (hadOwn) tui.doRender = originalRender;
217
- else delete tui.doRender;
222
+ if (originalRender) tui.doRender = originalRender;
218
223
  },
219
224
  };
220
225
  }
package/src/preview.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { promisify } from "node:util";
8
+ import { Image, truncateToWidth } from "@earendil-works/pi-tui";
9
+
10
+ const run = promisify(execFile);
11
+ const EXTENSIONS: Record<string, string> = {
12
+ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
13
+ "image/webp": "webp", "image/bmp": "bmp", "image/tiff": "tiff",
14
+ "image/avif": "avif", "image/heic": "heic",
15
+ };
16
+
17
+ let previewDirectory: string | undefined;
18
+
19
+ /** Retain temporary previews until this module's process exits, including across /reload. */
20
+ export function savePreview(data: string, mimeType: string): string {
21
+ const extension = EXTENSIONS[mimeType];
22
+ if (!extension) throw new Error(`Unsupported preview format: ${mimeType}`);
23
+ if (!previewDirectory) {
24
+ previewDirectory = mkdtempSync(join(tmpdir(), "pi-orca-images-"));
25
+ const directory = previewDirectory;
26
+ process.once("exit", () => {
27
+ try { rmSync(directory, { recursive: true, force: true }); } catch { /* OS temp cleanup is the fallback. */ }
28
+ });
29
+ }
30
+ const directory = previewDirectory;
31
+ const bytes = Buffer.from(data, "base64");
32
+ const path = join(directory, `${createHash("sha256").update(bytes).digest("hex")}.${extension}`);
33
+ writeFileSync(path, bytes, { mode: 0o600 });
34
+ return path;
35
+ }
36
+
37
+ export async function openInPreview(path: string): Promise<void> {
38
+ if (process.platform !== "darwin") throw new Error("Opening previews requires macOS Preview.");
39
+ await run("/usr/bin/open", ["-a", "Preview", path], { timeout: 10_000 });
40
+ }
41
+
42
+ const PREVIEW = Symbol.for("pi-orca-images.preview");
43
+ type PreviewHost = typeof Image.prototype & { [PREVIEW]?: { uninstall(): void } };
44
+
45
+ export function installPreviews(options: {
46
+ open?: (path: string) => Promise<void>;
47
+ save?: (data: string, mimeType: string) => string;
48
+ onError: (message: string) => void;
49
+ }) {
50
+ const prototype = Image.prototype as PreviewHost;
51
+ prototype[PREVIEW]?.uninstall();
52
+ const originalRender = prototype.render;
53
+ const paths = new WeakMap<Image, string | null>();
54
+ let latest: string | undefined;
55
+ let active = true;
56
+ const open = async (path: string) => {
57
+ try {
58
+ await (options.open ?? openInPreview)(path);
59
+ } catch (error) {
60
+ if (active) options.onError(`Could not open image in Preview: ${error instanceof Error ? error.message : String(error)}`);
61
+ }
62
+ };
63
+ prototype.render = function (width) {
64
+ const lines = originalRender.call(this, width);
65
+ if (!active) return lines;
66
+ if (!paths.has(this)) {
67
+ // Pi exposes no image-data accessor; fail closed if its internal fields change.
68
+ const source = this as unknown as { base64Data?: string; mimeType?: string };
69
+ try {
70
+ paths.set(this, typeof source.base64Data === "string" && typeof source.mimeType === "string"
71
+ ? (options.save ?? savePreview)(source.base64Data, source.mimeType) : null);
72
+ } catch {
73
+ paths.set(this, null);
74
+ }
75
+ }
76
+ const path = paths.get(this);
77
+ if (!path) return lines;
78
+ latest = path;
79
+ const label = truncateToWidth("Open image", width);
80
+ return [...lines, `\x1b]8;;${pathToFileURL(path).href}\x07\x1b[4m${label}\x1b[24m\x1b]8;;\x07`];
81
+ };
82
+ const preview = {
83
+ async openLatest() {
84
+ if (latest) await open(latest);
85
+ else options.onError("No image has been rendered yet. Try /orca-images test first.");
86
+ },
87
+ uninstall() {
88
+ if (!active) return;
89
+ active = false;
90
+ prototype.render = originalRender;
91
+ if (prototype[PREVIEW] === preview) delete prototype[PREVIEW];
92
+ },
93
+ };
94
+ prototype[PREVIEW] = preview;
95
+ return preview;
96
+ }
package/src/shim.ts CHANGED
@@ -85,7 +85,8 @@ export function installShim(tui: ShimTarget, options: ShimOptions = {}): Shim {
85
85
  cancel();
86
86
  if (shim.renderPatched) tui.doRender = originalRender;
87
87
  if (shim.focusPatched) tui.handleViewportInput = originalInput;
88
- delete host[SHIM];
88
+ // Assign, not delete: a delete through Pi's TUI Proxy never reaches the TUI.
89
+ host[SHIM] = undefined;
89
90
  },
90
91
  };
91
92