@kcurtet/pi-image-preview 0.0.0-stage → 0.1.1

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 (3) hide show
  1. package/README.md +79 -2
  2. package/index.ts +576 -0
  3. package/package.json +22 -5
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # pi-image-preview
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Hover previews for image paths in the [pi](https://github.com/earendil-works/pi) coding agent — inspired by [better-claude/image-preview](https://github.com/midudev/better-claude/tree/main/plugins/image-preview).
4
+
5
+ ```text
6
+ ▣ 1 screenshot.png 1920×1080 hover: preview · click: open · /images
7
+ ```
8
+
9
+ Paste an image, type an image path, or let the agent create one — and a row of **chips** appears. Hover a chip and the picture floats open right there, drawn by your terminal itself. Click it and it opens in your system's viewer.
10
+
11
+ ## ✨ Features
12
+
13
+ ### Chip row above the editor
14
+ While you type, pi-image-preview watches your draft (poll every 400 ms) and lists every image it mentions — pasted screenshots (`/tmp/pi-clipboard-*.png`), paths you typed, anything that exists on disk:
15
+
16
+ ```text
17
+ ▣ 1 screenshot.png 1920×1080 ▣ 2 chart.png 640×480 hover: preview · click: open
18
+ ──────────────────────────────────────────────
19
+ ❯ what is wrong in this screenshot? /tmp/screenshot.png
20
+ ```
21
+
22
+ ### Chips under sent messages and tool results
23
+ After you send a prompt — and after any tool call that produces or touches an image (a screenshot the agent took, a chart it generated, a `read` of an image file) — the same chips appear in the transcript. They survive resume: reopen the session and they are still there.
24
+
25
+ ```text
26
+ ❯ take a screenshot and tell me what you see
27
+ ⚙ bash: grim shot.png && ...
28
+ ▣ 1 shot.png 1920×1080 ← automatic
29
+ ⚙ read: shot.png
30
+ [inline image — native pi rendering]
31
+ ```
32
+
33
+ ### Hover preview
34
+ Put the pointer on any chip and a floating preview opens next to it — no click, no focus stealing, nothing moves. Move away and it closes. The picture renders through your terminal's native image protocol:
35
+
36
+ | Terminal | Rendering |
37
+ | --- | --- |
38
+ | kitty, Ghostty, WezTerm | kitty graphics protocol |
39
+ | iTerm2, others | iTerm2 protocol |
40
+ | No image support | informative card (name, size, format) |
41
+
42
+ Non-PNG images (JPEG, WebP, GIF, BMP) are converted automatically for kitty terminals.
43
+
44
+ ### Click to open
45
+ Click a chip and the image opens in your system's viewer — `open` on macOS, `explorer` on Windows, `xdg-open` on Linux.
46
+
47
+ ### `/images` — keyboard-only viewer
48
+ A picker over every image seen this session (draft, sent, tool-produced), then a full viewer:
49
+
50
+ ```text
51
+ q / esc close
52
+ o open externally
53
+ ← / → navigate (p / n also work)
54
+ ```
55
+
56
+ Works in any terminal, mouse or not.
57
+
58
+ ## 📦 Install
59
+
60
+ ```bash
61
+ pi install git:github.com/kcurtet/pi-image-preview
62
+ ```
63
+
64
+ Update with `pi update --extensions`, remove with `pi remove git:github.com/kcurtet/pi-image-preview`.
65
+
66
+ ## 🔍 How it works
67
+
68
+ - A 400 ms poll reads the editor text; path-like tokens are resolved against the filesystem and real images are loaded with magic-byte MIME detection (≤ 25 MB, max 12 at once).
69
+ - Chips are pi-tui `MouseRegion` components; the preview is a **non-capturing overlay** hosting pi-tui's `Image` component, positioned above the pointer.
70
+ - `tool_result` events are scanned for image paths (tool output + `path` / `command` / `file_path` inputs), deduplicated per session, and recorded as custom entries with an entry renderer — that is what makes transcript chips persist across resume.
71
+ - The `/images` history keeps the last 24 images. Everything is cleaned up on `session_shutdown`.
72
+
73
+ ## ⚠️ Limits
74
+
75
+ - Only paths that exist as local files are detected; URLs are not fetched.
76
+ - Chips appear under the tool *after* it finishes; streaming partials are not scanned.
77
+
78
+ ## 📄 License
79
+
80
+ MIT
package/index.ts ADDED
@@ -0,0 +1,576 @@
1
+ /**
2
+ * pi-image-preview — hover previews for image paths in the pi editor.
3
+ *
4
+ * Paste an image (pi inserts a /tmp/pi-clipboard-*.png path) or type any image
5
+ * path, and a chip row appears above the editor:
6
+ *
7
+ * ▣ 1 screenshot.png 1920×1080 hover: preview · click: open · /images
8
+ *
9
+ * - Hover a chip (fullscreen mode): a floating preview opens near the pointer,
10
+ * drawn with the terminal's image protocol (kitty / iTerm2) when available.
11
+ * - Click a chip: open the image in the system viewer (open / xdg-open / explorer).
12
+ * - `/images`: keyboard viewer over draft images plus recently sent ones
13
+ * (q/esc close · o open externally · ←/→ navigate).
14
+ *
15
+ * In terminals without mouse support (regular mode) the chip row still lists the
16
+ * images and /images always works.
17
+ */
18
+ import { spawn } from "node:child_process";
19
+ import fs from "node:fs";
20
+ import fsp from "node:fs/promises";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+
24
+ import {
25
+ Container,
26
+ HStack,
27
+ Image,
28
+ MouseRegion,
29
+ Text,
30
+ getCapabilities,
31
+ getImageDimensions,
32
+ type Component,
33
+ type ImageDimensions,
34
+ type OverlayHandle,
35
+ type TUI,
36
+ type TuiMouseEvent,
37
+ } from "@earendil-works/pi-tui";
38
+ import {
39
+ convertToPng,
40
+ detectSupportedImageMimeTypeFromFile,
41
+ type ExtensionAPI,
42
+ type Theme,
43
+ } from "@earendil-works/pi-coding-agent";
44
+
45
+ const WIDGET_KEY = "pi-image-preview";
46
+ const ENTRY_TYPE = "pi-image-preview";
47
+ const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
48
+ const MAX_IMAGES = 12;
49
+ const MAX_HISTORY = 24;
50
+ const POLL_MS = 400;
51
+ const HOVER_EXPIRY_MS = 450;
52
+ const EXPIRY_TICK_MS = 150;
53
+ const PREVIEW_MAX_CELLS = 60;
54
+ const PREVIEW_MAX_ROWS = 24;
55
+
56
+ interface LoadedImage {
57
+ path: string;
58
+ /** Base64 actually handed to the renderer (may be PNG-converted for kitty). */
59
+ base64: string;
60
+ mimeType: string;
61
+ origMime: string;
62
+ dims: ImageDimensions | null;
63
+ bytes: number;
64
+ }
65
+
66
+ type SetWidgetFn = (content: unknown, options?: { placement?: string }) => void;
67
+
68
+ /** Open a file with the platform viewer, detached. */
69
+ function openExternal(file: string): void {
70
+ try {
71
+ let cmd = "xdg-open";
72
+ if (process.platform === "darwin") cmd = "open";
73
+ else if (process.platform === "win32") cmd = "explorer";
74
+ const child = spawn(cmd, [file], { detached: true, stdio: "ignore" });
75
+ child.on("error", () => {});
76
+ child.unref();
77
+ } catch {
78
+ // best effort
79
+ }
80
+ }
81
+
82
+ function formatBytes(bytes: number): string {
83
+ if (bytes < 1024) return `${bytes} B`;
84
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
85
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
86
+ }
87
+
88
+ function formatDims(dims: ImageDimensions | null): string {
89
+ return dims ? `${dims.widthPx}×${dims.heightPx}` : "?×?";
90
+ }
91
+
92
+ /** Candidate image paths in a text: anything path-like that exists as a file. */
93
+ function extractCandidatePaths(text: string, cwd: string): string[] {
94
+ const tokens = text.match(/[^\s`'"|<>{}[\]()]+/g) ?? [];
95
+ const out: string[] = [];
96
+ const seen = new Set<string>();
97
+ for (const raw of tokens) {
98
+ const trimmed = raw.replace(/[,.:;!?]+$/, "");
99
+ if (!trimmed || trimmed.length > 4096) continue;
100
+ let candidate = trimmed;
101
+ if (candidate.startsWith("~")) candidate = path.join(os.homedir(), candidate.slice(1));
102
+ // Only image extensions count — "any path that exists" would chip log files,
103
+ // config files and every other path a tool prints.
104
+ if (!/\.(png|jpe?g|gif|webp|bmp)$/i.test(candidate)) continue;
105
+ const absolute = path.resolve(cwd, candidate);
106
+ if (seen.has(absolute)) continue;
107
+ try {
108
+ const stat = fs.statSync(absolute);
109
+ if (stat.isFile() && stat.size <= MAX_IMAGE_BYTES) {
110
+ seen.add(absolute);
111
+ out.push(absolute);
112
+ }
113
+ } catch {
114
+ // not a file — ignore
115
+ }
116
+ if (out.length >= MAX_IMAGES) break;
117
+ }
118
+ return out;
119
+ }
120
+
121
+ class ImagePreviewCard extends Container {
122
+ constructor(image: LoadedImage, private readonly theme: Theme) {
123
+ super();
124
+ this.rebuild(image);
125
+ }
126
+
127
+ private rebuild(image: LoadedImage): void {
128
+ this.clear();
129
+ const caps = getCapabilities();
130
+ const name = path.basename(image.path);
131
+ const meta = `${formatDims(image.dims)} · ${formatBytes(image.bytes)} · ${image.origMime.replace("image/", "")}`;
132
+ if (caps.images && image.base64) {
133
+ this.addChild(
134
+ new Image(
135
+ image.base64,
136
+ image.mimeType,
137
+ { fallbackColor: (s: string) => this.theme.fg("toolOutput", s) },
138
+ { maxWidthCells: PREVIEW_MAX_CELLS, maxHeightCells: PREVIEW_MAX_ROWS, filename: name },
139
+ image.dims ?? undefined,
140
+ ),
141
+ );
142
+ this.addChild(new Text(this.theme.fg("muted", `${name} · ${meta}`), 1, 0));
143
+ } else {
144
+ // Terminal cannot draw images: informative card instead.
145
+ this.addChild(new Text(this.theme.style(`▣ ${name}`, { fg: "accent", bold: true }), 1, 0));
146
+ this.addChild(new Text(this.theme.fg("muted", meta), 1, 0));
147
+ this.addChild(
148
+ new Text(this.theme.fg("dim", "terminal cannot draw images — click the chip to open"), 1, 0),
149
+ );
150
+ }
151
+ }
152
+ }
153
+
154
+ interface ImageRef {
155
+ path: string;
156
+ /** Loaded lazily; chips show "?×?" until ready. */
157
+ image?: LoadedImage;
158
+ }
159
+
160
+ class ChipsBar extends Container {
161
+ constructor(
162
+ private readonly mgr: PreviewManager,
163
+ private refs: ImageRef[] = [],
164
+ private readonly showHint = true,
165
+ ) {
166
+ super();
167
+ this.rebuild();
168
+ }
169
+
170
+ update(refs: ImageRef[]): void {
171
+ if (!Array.isArray(refs)) return;
172
+ this.refs = refs;
173
+ this.rebuild();
174
+ }
175
+
176
+ rebuild(): void {
177
+ this.clear();
178
+ if (this.refs.length === 0) return;
179
+ const theme = this.mgr.theme;
180
+ if (!theme) return;
181
+ const chips: Component[] = [];
182
+ this.refs.forEach((ref, index) => {
183
+ const name = path.basename(ref.path);
184
+ const dims = ref.image ? formatDims(ref.image.dims) : "…";
185
+ const label =
186
+ theme.style(` ▣${index + 1} `, { fg: "accent", bold: true }) +
187
+ theme.fg("text", name) +
188
+ theme.fg("muted", ` ${dims}`);
189
+ chips.push(
190
+ new MouseRegion(new Text(label, 0, 0), (event: TuiMouseEvent) => this.mgr.onChipMouse(ref, event)),
191
+ );
192
+ });
193
+ if (this.showHint) {
194
+ chips.push(new Text(theme.fg("dim", " hover: preview · click: open · /images"), 0, 0));
195
+ }
196
+ this.addChild(new HStack(chips, { gap: 1 }));
197
+ }
198
+ }
199
+
200
+ class ImageViewer implements Component {
201
+ private index: number;
202
+ private readonly imageComponents = new Map<number, Image>();
203
+
204
+ constructor(
205
+ private readonly tui: TUI,
206
+ private readonly theme: Theme,
207
+ private readonly images: LoadedImage[],
208
+ startIndex: number,
209
+ private readonly done: () => void,
210
+ ) {
211
+ this.index = Math.min(Math.max(startIndex, 0), images.length - 1);
212
+ }
213
+
214
+ handleInput(data: string): void {
215
+ if (data === "\x1b" || data === "q") {
216
+ this.done();
217
+ return;
218
+ }
219
+ if (data === "o" || data === "O") {
220
+ openExternal(this.images[this.index]!.path);
221
+ return;
222
+ }
223
+ let delta = 0;
224
+ if (data === "\x1b[D" || data === "p" || data === "P") delta = -1;
225
+ else if (data === "\x1b[C" || data === "n" || data === "N") delta = 1;
226
+ if (delta !== 0 && this.images.length > 1) {
227
+ this.index = (this.index + delta + this.images.length) % this.images.length;
228
+ this.imageComponents.clear();
229
+ this.tui.requestRender();
230
+ }
231
+ }
232
+
233
+ invalidate(): void {
234
+ for (const image of this.imageComponents.values()) image.invalidate();
235
+ }
236
+
237
+ render(width: number): string[] {
238
+ const image = this.images[this.index]!;
239
+ const name = path.basename(image.path);
240
+ const header =
241
+ this.theme.style(`▣ ${this.index + 1}/${this.images.length} `, { fg: "accent", bold: true }) +
242
+ this.theme.fg("text", name) +
243
+ this.theme.fg("muted", ` ${formatDims(image.dims)} · ${formatBytes(image.bytes)}`);
244
+ const footer = this.theme.fg("dim", "q close · o open · ←/→ navigate");
245
+ const innerWidth = Math.max(10, Math.min(width - 2, PREVIEW_MAX_CELLS));
246
+ const lines: string[] = ["", ...new Text(header, 1, 0).render(width)];
247
+ if (getCapabilities().images && image.base64) {
248
+ let component = this.imageComponents.get(this.index);
249
+ if (!component) {
250
+ component = new Image(
251
+ image.base64,
252
+ image.mimeType,
253
+ { fallbackColor: (s: string) => this.theme.fg("toolOutput", s) },
254
+ { maxWidthCells: innerWidth, maxHeightCells: PREVIEW_MAX_ROWS, filename: name },
255
+ image.dims ?? undefined,
256
+ );
257
+ this.imageComponents.set(this.index, component);
258
+ }
259
+ lines.push(...component.render(width));
260
+ } else {
261
+ lines.push(...new Text(this.theme.fg("muted", "(terminal cannot draw images)"), 1, 0).render(width));
262
+ }
263
+ lines.push(...new Text(footer, 1, 0).render(width));
264
+ return lines;
265
+ }
266
+ }
267
+
268
+ class PreviewManager {
269
+ cwd: string;
270
+ theme: Theme | undefined;
271
+ private tui: TUI | undefined;
272
+ private bar: ChipsBar | undefined;
273
+ private currentPaths: string[] = [];
274
+ private readonly cache = new Map<string, LoadedImage>();
275
+ private readonly loading = new Set<string>();
276
+ private readonly sentHistory: LoadedImage[] = [];
277
+ private lastText: string | null = null;
278
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
279
+ private expiryTimer: ReturnType<typeof setInterval> | undefined;
280
+ private overlay: OverlayHandle | undefined;
281
+ private overlayPath: string | undefined;
282
+ private lastMouseAt = 0;
283
+ private unregisterWidget: (() => void) | undefined;
284
+
285
+ constructor(private readonly getEditorText: () => string | undefined) {
286
+ this.cwd = process.cwd();
287
+ }
288
+
289
+ start(setWidget: SetWidgetFn): void {
290
+ this.stop();
291
+ // One always-registered widget: an empty bar renders no lines, so the cost
292
+ // matches the default layout (the container keeps its leading spacer).
293
+ setWidget(
294
+ (tui: TUI, theme: Theme) => {
295
+ this.tui = tui;
296
+ this.theme = theme;
297
+ this.bar = new ChipsBar(this, this.currentRefs());
298
+ return this.bar;
299
+ },
300
+ { placement: "aboveEditor" },
301
+ );
302
+ this.unregisterWidget = () => setWidget(undefined);
303
+ this.pollTimer = setInterval(() => void this.poll(), POLL_MS);
304
+ this.expiryTimer = setInterval(() => this.checkExpiry(), EXPIRY_TICK_MS);
305
+ }
306
+
307
+ stop(): void {
308
+ if (this.pollTimer) clearInterval(this.pollTimer);
309
+ if (this.expiryTimer) clearInterval(this.expiryTimer);
310
+ this.pollTimer = this.expiryTimer = undefined;
311
+ this.hidePreview();
312
+ this.unregisterWidget?.();
313
+ this.unregisterWidget = undefined;
314
+ this.bar = undefined;
315
+ this.currentPaths = [];
316
+ this.lastText = null;
317
+ }
318
+
319
+ private currentRefs(): ImageRef[] {
320
+ return this.currentPaths.map((p) => ({ path: p, image: this.cache.get(p) }));
321
+ }
322
+
323
+ private refreshBar(): void {
324
+ this.bar?.update(this.currentRefs());
325
+ this.bar?.invalidate();
326
+ this.tui?.requestRender();
327
+ }
328
+
329
+ /** Kick async loads for paths not in cache; onChange runs after each settles. */
330
+ ensureLoaded(paths: string[], onChange?: () => void): void {
331
+ for (const p of paths) {
332
+ if (this.cache.has(p) || this.loading.has(p)) continue;
333
+ this.loading.add(p);
334
+ void this.load(p)
335
+ .then((loaded) => {
336
+ if (loaded) this.cache.set(p, loaded);
337
+ })
338
+ .catch(() => {})
339
+ .finally(() => {
340
+ this.loading.delete(p);
341
+ onChange?.();
342
+ });
343
+ }
344
+ }
345
+
346
+ cacheGet(pathname: string): LoadedImage | undefined {
347
+ return this.cache.get(pathname);
348
+ }
349
+
350
+ renderRequest(): void {
351
+ this.tui?.requestRender();
352
+ }
353
+
354
+ private async poll(): Promise<void> {
355
+ const text = this.getEditorText();
356
+ if (text === undefined || text === this.lastText) return;
357
+ this.lastText = text;
358
+ this.hidePreview();
359
+ const paths = extractCandidatePaths(text, this.cwd);
360
+ this.ensureLoaded(paths, () => {
361
+ this.syncCurrent();
362
+ this.refreshBar();
363
+ });
364
+ this.syncCurrent(paths);
365
+ this.refreshBar();
366
+ }
367
+
368
+ private syncCurrent(paths?: string[]): void {
369
+ const wanted = paths ?? extractCandidatePaths(this.lastText ?? "", this.cwd);
370
+ this.currentPaths = wanted;
371
+ }
372
+
373
+ private async load(pathname: string): Promise<LoadedImage | null> {
374
+ try {
375
+ const mime = await detectSupportedImageMimeTypeFromFile(pathname);
376
+ if (!mime) return null;
377
+ const stat = await fsp.stat(pathname);
378
+ if (!stat.isFile() || stat.size > MAX_IMAGE_BYTES) return null;
379
+ let base64 = await fsp.readFile(pathname, "base64");
380
+ let encMime = mime;
381
+ if (getCapabilities().images === "kitty" && mime !== "image/png") {
382
+ const converted = await convertToPng(base64, mime);
383
+ if (converted) {
384
+ base64 = converted.data;
385
+ encMime = converted.mimeType;
386
+ }
387
+ }
388
+ const dims = getImageDimensions(base64, encMime) ?? getImageDimensions(base64, mime);
389
+ return { path: pathname, base64, mimeType: encMime, origMime: mime, dims, bytes: stat.size };
390
+ } catch {
391
+ return null;
392
+ }
393
+ }
394
+
395
+ /** Track images from sent messages for the /images viewer. */
396
+ recordSent(text: string): void {
397
+ const paths = extractCandidatePaths(text, this.cwd);
398
+ if (paths.length === 0) return;
399
+ void Promise.all(
400
+ paths.map((p) => (this.cache.has(p) ? Promise.resolve(this.cache.get(p)!) : this.load(p))),
401
+ )
402
+ .then((loaded) => {
403
+ for (const image of loaded) {
404
+ if (!image) continue;
405
+ this.cache.set(image.path, image);
406
+ const existing = this.sentHistory.findIndex((entry) => entry.path === image.path);
407
+ if (existing !== -1) this.sentHistory.splice(existing, 1);
408
+ this.sentHistory.unshift(image);
409
+ }
410
+ while (this.sentHistory.length > MAX_HISTORY) this.sentHistory.pop();
411
+ })
412
+ .catch(() => {});
413
+ }
414
+
415
+ get images(): LoadedImage[] {
416
+ const seen = new Set<string>();
417
+ const all: LoadedImage[] = [];
418
+ const push = (image: LoadedImage | undefined) => {
419
+ if (image && !seen.has(image.path)) {
420
+ seen.add(image.path);
421
+ all.push(image);
422
+ }
423
+ };
424
+ for (const p of this.currentPaths) push(this.cache.get(p));
425
+ for (const image of this.sentHistory) push(image);
426
+ return all;
427
+ }
428
+
429
+ onChipMouse(ref: ImageRef, event: TuiMouseEvent) {
430
+ this.lastMouseAt = Date.now();
431
+ if (event.type === "click" && event.button === "left") {
432
+ openExternal(ref.path);
433
+ this.hidePreview();
434
+ return { handled: true };
435
+ }
436
+ if (event.type === "move" || event.type === "press" || event.type === "drag") {
437
+ // Resolve from cache at event time so freshly loaded images preview too.
438
+ const image = ref.image ?? this.cache.get(ref.path);
439
+ if (image) this.showPreview(image, event.screenX, event.screenY);
440
+ }
441
+ return undefined;
442
+ }
443
+
444
+ private showPreview(image: LoadedImage, screenX: number, screenY: number): void {
445
+ if (!this.tui || !this.theme) return;
446
+ if (this.overlay && this.overlayPath === image.path) return; // already showing this one
447
+ this.hidePreview();
448
+ const card = new ImagePreviewCard(image, this.theme);
449
+ const cols = process.stdout.columns || 80;
450
+ const rows = process.stdout.rows || 24;
451
+ const width = Math.max(20, Math.min(cols - 2, PREVIEW_MAX_CELLS + 2));
452
+ const height = card.render(width).length;
453
+ const col = Math.max(0, Math.min(screenX + 2, cols - width - 1));
454
+ const row =
455
+ screenY - height - 1 >= 0
456
+ ? screenY - height - 1
457
+ : Math.min(screenY + 1, Math.max(0, rows - height - 1));
458
+ this.overlay = this.tui.showOverlay(card, { row, col, width, nonCapturing: true });
459
+ this.overlayPath = image.path;
460
+ }
461
+
462
+ hidePreview(): void {
463
+ if (this.overlay) {
464
+ try {
465
+ this.overlay.hide();
466
+ } catch {
467
+ // tui may have been replaced (mode switch)
468
+ }
469
+ }
470
+ this.overlay = undefined;
471
+ this.overlayPath = undefined;
472
+ }
473
+
474
+ private checkExpiry(): void {
475
+ if (this.overlay && Date.now() - this.lastMouseAt > HOVER_EXPIRY_MS) this.hidePreview();
476
+ }
477
+ }
478
+
479
+ export default function imagePreviewExtension(pi: ExtensionAPI) {
480
+ // One manager per extension runtime; start() is idempotent and re-binds on
481
+ // session switches, so old timers and widgets never linger.
482
+ let mgr: PreviewManager | undefined;
483
+
484
+ pi.on("session_start", (_event, ctx) => {
485
+ if (ctx.mode !== "tui" || !ctx.hasUI) return;
486
+ mgr = new PreviewManager(() => {
487
+ try {
488
+ return ctx.ui.getEditorText();
489
+ } catch {
490
+ return undefined;
491
+ }
492
+ });
493
+ mgr.cwd = ctx.sessionManager.getCwd();
494
+ mgr.start((content, options) => ctx.ui.setWidget(WIDGET_KEY, content as never, options as never));
495
+ });
496
+
497
+ // Sent messages get a chips entry right below them in the transcript: same
498
+ // hover preview / click-to-open as the editor bar, and it survives resume.
499
+ pi.registerEntryRenderer(ENTRY_TYPE, (entry, _options, theme) => {
500
+ if (!mgr) return undefined;
501
+ mgr.theme = mgr.theme ?? theme;
502
+ const paths = (entry.data as { paths?: string[] } | undefined)?.paths ?? [];
503
+ if (paths.length === 0) return undefined;
504
+ const refsFor = (): ImageRef[] => paths.map((p) => ({ path: p, image: mgr!.cacheGet(p) }));
505
+ const bar = new ChipsBar(mgr, refsFor(), false);
506
+ mgr.ensureLoaded(paths, () => {
507
+ bar.update(refsFor());
508
+ mgr!.renderRequest();
509
+ });
510
+ return bar;
511
+ });
512
+
513
+ pi.on("input", (event) => {
514
+ if (event.source === "extension" || !event.text) return;
515
+ const paths = mgr ? extractCandidatePaths(event.text, mgr.cwd) : [];
516
+ if (paths.length > 0) pi.appendEntry(ENTRY_TYPE, { paths });
517
+ mgr?.recordSent(event.text);
518
+ });
519
+
520
+ // Tool results: when the agent produces or reads images (screenshot via bash,
521
+ // a generated chart, read of an image file), leave chips under the tool row.
522
+ const recorded = new Set<string>();
523
+ pi.on("tool_result", (event) => {
524
+ if (!mgr) return;
525
+ const texts: string[] = [];
526
+ for (const block of event.content) {
527
+ if (block.type === "text") texts.push(block.text);
528
+ }
529
+ if (typeof event.input?.path === "string") texts.push(event.input.path);
530
+ if (typeof event.input?.command === "string") texts.push(event.input.command);
531
+ if (typeof event.input?.file_path === "string") texts.push(event.input.file_path);
532
+ const paths = extractCandidatePaths(texts.join("\n"), mgr.cwd).filter((p) => !recorded.has(p));
533
+ if (paths.length === 0) return;
534
+ for (const p of paths) recorded.add(p);
535
+ pi.appendEntry(ENTRY_TYPE, { paths, source: "tool" });
536
+ mgr.recordSent(paths.join(" "));
537
+ });
538
+
539
+ pi.on("session_shutdown", () => {
540
+ mgr?.stop();
541
+ mgr = undefined;
542
+ });
543
+
544
+ pi.registerCommand("images", {
545
+ description: "Preview images from the editor draft and recent messages",
546
+ handler: async (_args, ctx) => {
547
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
548
+ ctx.ui.notify("Image preview needs the interactive UI", "warning");
549
+ return;
550
+ }
551
+ let images: LoadedImage[] = mgr ? mgr.images : [];
552
+ if (images.length === 0) {
553
+ ctx.ui.notify("No images detected — paste one or type an image path", "info");
554
+ return;
555
+ }
556
+ const labels = images.map(
557
+ (image, index) =>
558
+ `${index + 1}) ${path.basename(image.path)} · ${formatDims(image.dims)} · ${formatBytes(image.bytes)}`,
559
+ );
560
+ const choice = await ctx.ui.select("Image preview", labels);
561
+ if (choice === undefined) return;
562
+ const index = Math.max(0, labels.indexOf(choice));
563
+ await ctx.ui.custom(
564
+ (tui, theme, _keybindings, done) =>
565
+ new ImageViewer(tui, theme, images, index, () => done(undefined)),
566
+ {
567
+ overlay: true,
568
+ overlayOptions: {
569
+ anchor: "center",
570
+ width: Math.min((process.stdout.columns || 80) - 4, 68),
571
+ },
572
+ },
573
+ );
574
+ },
575
+ });
576
+ }
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
- "name": "@kcurtet/pi-image-preview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@kcurtet/pi-image-preview",
3
+ "version": "0.1.1",
4
+ "description": "Hover previews for image paths in the pi coding agent: chips above the editor and in the transcript, kitty/iTerm2 inline rendering, /images viewer",
5
+ "private": false,
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/kcurtet/pi-image-preview.git"
13
+ },
14
+ "pi": {
15
+ "extensions": [
16
+ "./index.ts"
17
+ ]
18
+ },
19
+ "peerDependencies": {
20
+ "@earendil-works/pi-coding-agent": "*",
21
+ "@earendil-works/pi-tui": "*"
22
+ }
23
+ }