@prjct.app/pi-clipboard 0.1.3 → 0.1.5

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,3 +1,16 @@
1
+ ## [0.1.5](https://github.com/prjct-app/pi-clipboard/compare/v0.1.4...v0.1.5) (2026-09-10)
2
+
3
+ ### Bug Fixes
4
+
5
+ * recognize timestamped Pi clipboard attachments ([f9769f4](https://github.com/prjct-app/pi-clipboard/commit/f9769f46d25cb90a19913b2486d0bbd61f6a4c9d))
6
+
7
+ ## [0.1.4](https://github.com/prjct-app/pi-clipboard/compare/v0.1.3...v0.1.4) (2026-09-10)
8
+
9
+ ### Bug Fixes
10
+
11
+ * delete previewed clipboard temp files on session shutdown ([a64a2b4](https://github.com/prjct-app/pi-clipboard/commit/a64a2b446565fc7867395186097a63b62779e3f4))
12
+ * render clipboard previews with Pi's native image components ([312295b](https://github.com/prjct-app/pi-clipboard/commit/312295b422d2c29bf5708be88c3254cb4bd17e45))
13
+
1
14
  # Changelog
2
15
 
3
16
  ## 0.1.3
package/CONTRIBUTING.md CHANGED
@@ -12,3 +12,7 @@
12
12
  ## Package documentation
13
13
 
14
14
  Follow [docs/package.md](docs/package.md) and its versioned official references. Keep README examples consistent with registered commands, distinguish tested behavior from unverified compatibility, and verify `npm run check:package` before release.
15
+
16
+ ## Releases
17
+
18
+ Merging a releasable change into `main` automatically publishes to npm. Use conventional commit messages and read [Automatic releases](docs/releases.md) before merging. The workflow manages versions and authenticates with npm through OIDC.
package/README.md CHANGED
@@ -24,9 +24,9 @@ For project-only installation, add `-l`: `pi install -l npm:@prjct.app/pi-clipbo
24
24
  2. Paste an image using Pi's native clipboard flow.
25
25
  3. Inspect the preview above the editor, then submit your message normally.
26
26
 
27
- Previews appear automatically; there is no slash command to enable them. Removing an attachment path from the editor removes its preview. Multiple images form a gallery.
27
+ Previews appear automatically; there is no slash command to enable them. Removing an attachment path from the editor removes its preview. Each attachment renders as its own preview.
28
28
 
29
- Only Pi-created temporary clipboard attachments are eligible. Arbitrary image paths do not produce previews. Supported filenames end in PNG, JPEG, WebP, or GIF; individual files over 50 MiB and symbolic links are rejected. Inline thumbnails depend on Pi's terminal image capabilities; other terminals show text cards. The extension itself makes no network requests. Submitting an image still follows Pi's normal model-provider attachment flow.
29
+ Only Pi-created temporary clipboard attachments are eligible. The extension recognizes Pi's UUID (`pi-clipboard-<UUID>`) and timestamped (`clipboard-<date>-<time>-<hex>`) temporary filename forms; arbitrary image paths do not produce previews. Supported filenames end in PNG, JPEG, WebP, or GIF; individual files over 50 MiB and symbolic links are rejected. Clipboard images stay on disk as the temporary files Pi created; the files this session previewed are deleted when the Pi session ends. Previews render through Pi's native terminal image support; terminals without it show Pi's native `[Image: ...]` text fallback. The extension itself makes no network requests. Submitting an image still follows Pi's normal model-provider attachment flow.
30
30
 
31
31
 
32
32
  ## Manage the package
@@ -47,11 +47,11 @@ When switching from GitHub to npm, remove the Git installation first, then insta
47
47
 
48
48
  ## Troubleshooting
49
49
 
50
- If no preview appears, confirm you are in interactive TUI mode and pasted through Pi. Text cards are the expected fallback when inline images are unavailable.
50
+ If no preview appears, confirm you are in interactive TUI mode and pasted through Pi. The `[Image: ...]` text line is the expected fallback when inline images are unavailable.
51
51
 
52
52
  ## Package and API documentation
53
53
 
54
- Uses `session_start`, `session_shutdown`, `ctx.ui.getEditorText()`, `ctx.ui.setWidget()`, and Pi TUI image components. It retains the native editor and attachment submission flow.
54
+ Uses `session_start`, `session_shutdown`, `ctx.ui.getEditorText()`, `ctx.ui.setWidget()`, and Pi TUI `Image`/`Container` components. Kitty placements receive PNG payloads through Pi's native `convertToPng`. Clipboard files previewed by the session are deleted on `session_shutdown` after revalidating that they are still Pi-owned temporary files. It retains the native editor and attachment submission flow.
55
55
 
56
56
  See [Package structure and compatibility](docs/package.md) for the manifest, dependency policy, shipped resources, and official references. This package follows the [official Pi package guide](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/packages.md) and [extension API guide](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/extensions.md) for the tested version.
57
57
 
package/docs/package.md CHANGED
@@ -35,7 +35,7 @@ Third-party runtime dependencies belong in `dependencies`. Companion extensions
35
35
 
36
36
  ## Public interfaces
37
37
 
38
- Uses `session_start`, `session_shutdown`, `ctx.ui.getEditorText()`, `ctx.ui.setWidget()`, and Pi TUI image components. It retains the native editor and attachment submission flow.
38
+ Uses `session_start`, `session_shutdown`, `ctx.ui.getEditorText()`, `ctx.ui.setWidget()`, and Pi TUI `Image`/`Container` components. It recognizes Pi's UUID and timestamped temporary clipboard filename forms in the system temporary directory. Kitty placements receive PNG payloads through Pi's native `convertToPng`. Clipboard files previewed by the session are deleted on `session_shutdown` after revalidating that they are still Pi-owned temporary files. It retains the native editor and attachment submission flow.
39
39
 
40
40
  ## Published contents
41
41
 
@@ -0,0 +1,29 @@
1
+ # Automatic releases
2
+
3
+ Merging a releasable change into `main` starts the **Release** workflow. It checks TypeScript, runs the tests, and checks the package contents before publishing.
4
+
5
+ The workflow uses semantic-release to calculate the version, update `package.json`, `package-lock.json` and `CHANGELOG.md`, create a `vX.Y.Z` tag, publish to npm, and create a GitHub release. Release-tool dependencies are locked separately under `.github/release/` and are not installed with the extension.
6
+
7
+ ## Commit messages
8
+
9
+ - `fix:` and `perf:` publish a patch version.
10
+ - `feat:` publishes a minor version.
11
+ - A `BREAKING CHANGE:` footer or a conventional `!` marker publishes a major version.
12
+ - `docs:`, `refactor:`, `build:`, `ci(release):`, and `chore(deps):` publish a patch version.
13
+ - Other changes, such as tests alone, do not publish a version.
14
+
15
+ Use these prefixes on commits. When squash merging, use a conventional prefix in the pull request title. Let the workflow manage release versions instead of editing the version by hand.
16
+
17
+ ## Authentication
18
+
19
+ npm trusts this repository's `.github/workflows/release.yml` through GitHub Actions OIDC. No npm token or interactive one-time password is needed for each release. The workflow is restricted to `main`; it uses GitHub's short-lived repository token to write the version commit, tag, and release. Private repositories do not produce npm provenance attestations.
20
+
21
+ The corresponding npm trusted publisher must use organization `prjct-app`, this repository's name, workflow filename `release.yml`, no environment name, and permission to publish directly with `npm publish`.
22
+
23
+ ## Preview and recovery
24
+
25
+ Run **Release** from the Actions tab on `main` with `dry_run` enabled to preview the next version and release notes. No version commit, tag, npm publication, or GitHub release is created by a dry run.
26
+
27
+ Runs are serialized and an outdated checkout is skipped. Never cancel a run during publication. If a run fails, inspect its logs and the existing npm version and GitHub tag before retrying: publication is not a transaction across both services. Do not delete a published version or move an existing release tag to recover.
28
+
29
+ References: [semantic-release](https://semantic-release.gitbook.io/semantic-release/), [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-clipboard",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Preview pasted images in PI Agent before sending them, with inline thumbnails and a compact attachment gallery.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -61,6 +61,7 @@
61
61
  "LICENSE",
62
62
  "CONTRIBUTING.md",
63
63
  "CHANGELOG.md",
64
- "docs/package.md"
64
+ "docs/package.md",
65
+ "docs/releases.md"
65
66
  ]
66
67
  }
package/src/index.ts CHANGED
@@ -1,30 +1,28 @@
1
1
  import { constants } from "node:fs";
2
- import { lstat, open } from "node:fs/promises";
2
+ import { lstat, open, unlink } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, dirname, extname, resolve } from "node:path";
5
5
 
6
6
  import {
7
7
  convertToPng,
8
- resizeImage,
9
8
  type ExtensionAPI,
10
9
  type ExtensionContext,
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
  import {
12
+ Container,
13
13
  getCapabilities,
14
14
  getImageDimensions,
15
- imageFallback,
16
15
  Image,
17
- truncateToWidth,
18
- visibleWidth,
16
+ imageFallback,
17
+ Text,
19
18
  type Component,
20
19
  type ImageDimensions,
21
- type TUI,
22
20
  } from "@earendil-works/pi-tui";
23
21
 
24
- import { decodePng, encodePng, type RgbaImage } from "./png.ts";
25
- import { blit, createImage, fillRoundedRect, fitWithin, strokeRoundedRect, type Rgba } from "./raster.ts";
26
-
27
- const CLIPBOARD_IMAGE_NAME_SOURCE = "pi-clipboard-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.(?:png|jpe?g|webp|gif)";
22
+ const IMAGE_EXTENSION_SOURCE = "(?:png|jpe?g|webp|gif)";
23
+ const UUID_CLIPBOARD_IMAGE_NAME_SOURCE = `pi-clipboard-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.${IMAGE_EXTENSION_SOURCE}`;
24
+ const TIMESTAMPED_CLIPBOARD_IMAGE_NAME_SOURCE = `clipboard-[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])-(?:[01][0-9]|2[0-3])[0-5][0-9][0-5][0-9]-[0-9a-f]{8}\\.${IMAGE_EXTENSION_SOURCE}`;
25
+ const CLIPBOARD_IMAGE_NAME_SOURCE = `(?:${UUID_CLIPBOARD_IMAGE_NAME_SOURCE}|${TIMESTAMPED_CLIPBOARD_IMAGE_NAME_SOURCE})`;
28
26
  const CLIPBOARD_IMAGE_FILENAME = new RegExp(CLIPBOARD_IMAGE_NAME_SOURCE, "gi");
29
27
  const CLIPBOARD_IMAGE_BASENAME = new RegExp(`^${CLIPBOARD_IMAGE_NAME_SOURCE}$`, "i");
30
28
  const IMAGE_MIME_TYPES: Record<string, string> = {
@@ -38,39 +36,16 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
38
36
  const WIDGET_KEY = "pi-image-preview";
39
37
  const DEFAULT_POLL_INTERVAL_MS = 150;
40
38
  const MAX_PREVIEW_FILE_BYTES = 50 * 1024 * 1024;
41
- const CARD_WIDTH_CELLS = 14;
42
- const FALLBACK_CARD_WIDTH_CELLS = 28;
43
- const CARD_GAP_CELLS = 1;
44
- const MAX_CARDS_PER_ROW = 6;
45
- const CARD_WIDTH_PX = 160;
46
- const CARD_HEIGHT_PX = 112;
47
- const CARD_GAP_PX = 8;
48
- const THUMBNAIL_INSET_PX = 8;
49
- const THUMBNAIL_WIDTH_PX = CARD_WIDTH_PX - THUMBNAIL_INSET_PX * 2;
50
- const THUMBNAIL_HEIGHT_PX = CARD_HEIGHT_PX - THUMBNAIL_INSET_PX * 2;
51
- const CARD_RADIUS_PX = 16;
52
- const CARD_BORDER_PX = 2;
53
- const THUMBNAIL_RADIUS_PX = 12;
54
- const CARD_FILL: Rgba = [0x11, 0x18, 0x27, Math.round(0.92 * 255)];
55
- const CARD_BORDER: Rgba = [0x9c, 0xa3, 0xaf, 255];
39
+ const PREVIEW_MAX_WIDTH_CELLS = 60;
40
+ const PREVIEW_MAX_HEIGHT_CELLS = 12;
56
41
 
57
42
  interface PreviewSource {
58
43
  filePath: string;
59
- data: Buffer;
44
+ base64: string;
60
45
  mimeType: string;
61
46
  dimensions: ImageDimensions;
62
- }
63
-
64
- interface GalleryLayout {
65
- columns: number;
66
- rows: number;
67
- displayWidth: number;
68
- displayHeight: number;
69
- }
70
-
71
- interface RenderedGallery {
72
- signature: string;
73
- image: Image;
47
+ /** PNG variant for Kitty placements (f=100 only decodes PNG). Undefined while unconverted. */
48
+ kittyBase64?: string;
74
49
  }
75
50
 
76
51
  interface Scheduler {
@@ -88,7 +63,7 @@ const systemScheduler: Scheduler = {
88
63
  clearInterval: (handle) => clearInterval(handle),
89
64
  };
90
65
 
91
- /** Resolve only filenames produced by Pi's native clipboard flow. */
66
+ /** Resolve only filenames produced by Pi's native clipboard flows. */
92
67
  export function clipboardImagePaths(editorText: string): string[] {
93
68
  const paths = new Set<string>();
94
69
  const matcher = new RegExp(CLIPBOARD_IMAGE_FILENAME.source, CLIPBOARD_IMAGE_FILENAME.flags);
@@ -122,11 +97,12 @@ async function readPreviewSource(filePath: string): Promise<PreviewSource | unde
122
97
  ) return undefined;
123
98
 
124
99
  const data = await handle.readFile();
100
+ const base64 = data.toString("base64");
125
101
  return {
126
102
  filePath,
127
- data,
103
+ base64,
128
104
  mimeType,
129
- dimensions: getImageDimensions(data.toString("base64"), mimeType) ?? { widthPx: 800, heightPx: 600 },
105
+ dimensions: getImageDimensions(base64, mimeType) ?? { widthPx: 800, heightPx: 600 },
130
106
  };
131
107
  } finally {
132
108
  await handle.close();
@@ -136,161 +112,99 @@ async function readPreviewSource(filePath: string): Promise<PreviewSource | unde
136
112
  }
137
113
  }
138
114
 
139
- function galleryLayout(width: number, count: number): GalleryLayout {
140
- const available = Math.max(1, width);
141
- const columns = Math.min(
142
- count,
143
- MAX_CARDS_PER_ROW,
144
- Math.max(1, Math.floor((available + CARD_GAP_CELLS) / (CARD_WIDTH_CELLS + CARD_GAP_CELLS))),
145
- );
146
- const rows = Math.ceil(count / columns);
147
- return {
148
- columns,
149
- rows,
150
- displayWidth: Math.min(available, columns * CARD_WIDTH_CELLS + (columns - 1) * CARD_GAP_CELLS),
151
- displayHeight: rows * 7 + Math.max(0, rows - 1),
152
- };
153
- }
154
-
155
- function padLine(text: string, width: number): string {
156
- const truncated = truncateToWidth(text, width);
157
- return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
158
- }
159
-
160
- async function thumbnailPixels(source: PreviewSource): Promise<RgbaImage | undefined> {
161
- let png: Uint8Array | undefined;
115
+ /**
116
+ * Delete a clipboard image previously previewed by this session. Revalidates
117
+ * the same containment rules used for reads before unlinking, so only real
118
+ * Pi-owned temporary clipboard files are removed; anything else stays put.
119
+ */
120
+ async function deleteClipboardImage(filePath: string): Promise<void> {
121
+ if (dirname(resolve(filePath)) !== resolve(tmpdir())) return;
122
+ if (!CLIPBOARD_IMAGE_BASENAME.test(basename(filePath))) return;
162
123
  try {
163
- const resized = await resizeImage(source.data, source.mimeType, {
164
- maxWidth: THUMBNAIL_WIDTH_PX,
165
- maxHeight: THUMBNAIL_HEIGHT_PX,
166
- });
167
- const converted = resized ? await convertToPng(resized.data, resized.mimeType) : null;
168
- if (converted) png = Buffer.from(converted.data, "base64");
124
+ const info = await lstat(filePath);
125
+ if (!info.isFile() || info.isSymbolicLink()) return;
126
+ await unlink(filePath);
169
127
  } catch {
170
- png = undefined;
128
+ // Already removed or unreadable; nothing to clean up.
171
129
  }
172
- if (!png && source.mimeType === "image/png") png = source.data;
173
- const decoded = png ? decodePng(png) : null;
174
- return decoded ? fitWithin(decoded, THUMBNAIL_WIDTH_PX, THUMBNAIL_HEIGHT_PX) : undefined;
175
130
  }
176
131
 
177
- async function roundedCard(source: PreviewSource): Promise<RgbaImage> {
178
- const card = createImage(CARD_WIDTH_PX, CARD_HEIGHT_PX);
179
- const outline = { x: 0, y: 0, width: CARD_WIDTH_PX, height: CARD_HEIGHT_PX };
180
- fillRoundedRect(card, {
181
- x: CARD_BORDER_PX / 2,
182
- y: CARD_BORDER_PX / 2,
183
- width: CARD_WIDTH_PX - CARD_BORDER_PX,
184
- height: CARD_HEIGHT_PX - CARD_BORDER_PX,
185
- }, CARD_RADIUS_PX - CARD_BORDER_PX / 2, CARD_FILL);
186
- strokeRoundedRect(card, outline, CARD_RADIUS_PX, CARD_BORDER_PX, CARD_BORDER);
187
-
188
- const thumbnail = await thumbnailPixels(source);
189
- if (thumbnail) {
190
- const box = { x: THUMBNAIL_INSET_PX, y: THUMBNAIL_INSET_PX, width: THUMBNAIL_WIDTH_PX, height: THUMBNAIL_HEIGHT_PX };
191
- const left = box.x + Math.floor((box.width - thumbnail.width) / 2);
192
- const top = box.y + Math.floor((box.height - thumbnail.height) / 2);
193
- blit(card, thumbnail, left, top, { rect: box, radius: THUMBNAIL_RADIUS_PX });
132
+ /**
133
+ * Kitty graphics placements (f=100) only decode PNG, so non-PNG sources need a
134
+ * PNG variant. Conversion uses Pi's native `convertToPng` (bundled WASM, no
135
+ * external tools) and runs once per source, off the render path.
136
+ */
137
+ async function prepareKittyVariant(source: PreviewSource): Promise<void> {
138
+ if (source.mimeType === "image/png") {
139
+ source.kittyBase64 = source.base64;
140
+ return;
141
+ }
142
+ try {
143
+ const converted = await convertToPng(source.base64, source.mimeType);
144
+ source.kittyBase64 = converted?.data;
145
+ } catch {
146
+ source.kittyBase64 = undefined;
194
147
  }
195
- return card;
196
- }
197
-
198
- async function buildGallery(sources: PreviewSource[], layout: GalleryLayout): Promise<Buffer> {
199
- const cards = await Promise.all(sources.map(roundedCard));
200
- const width = layout.columns * CARD_WIDTH_PX + (layout.columns - 1) * CARD_GAP_PX;
201
- const height = layout.rows * CARD_HEIGHT_PX + (layout.rows - 1) * CARD_GAP_PX;
202
- const gallery = createImage(width, height);
203
- cards.forEach((card, index) => {
204
- blit(
205
- gallery,
206
- card,
207
- (index % layout.columns) * (CARD_WIDTH_PX + CARD_GAP_PX),
208
- Math.floor(index / layout.columns) * (CARD_HEIGHT_PX + CARD_GAP_PX),
209
- );
210
- });
211
- return encodePng(gallery);
212
148
  }
213
149
 
214
- class ImagePreviewGallery implements Component {
215
- private gallery?: RenderedGallery;
216
- private pendingSignature?: string;
217
- private buildGeneration = 0;
150
+ /**
151
+ * Renders one native Pi `Image` per clipboard attachment inside a `Container`.
152
+ * The `Image` component owns Kitty/iTerm2 placement and the `[Image: ...]`
153
+ * text fallback for terminals without inline-image support.
154
+ */
155
+ class ClipboardPreviewGallery implements Component {
156
+ private container?: Container;
157
+ private containerKey?: string;
218
158
 
219
159
  constructor(
220
- private readonly tui: TUI,
221
160
  private readonly sources: PreviewSource[],
222
161
  private readonly fallbackColor: (text: string) => string,
223
162
  ) {}
224
163
 
225
- private renderFallback(width: number): string[] {
226
- const innerWidth = Math.max(1, Math.min(FALLBACK_CARD_WIDTH_CELLS - 2, width - 2));
227
- const cardWidth = innerWidth + 2;
228
- const columns = Math.max(1, Math.floor((width + CARD_GAP_CELLS) / (cardWidth + CARD_GAP_CELLS)));
229
- const lines: string[] = [];
230
-
231
- for (let start = 0; start < this.sources.length; start += columns) {
232
- const row = this.sources.slice(start, start + columns);
233
- const cards = row.map((source) => {
234
- const label = imageFallback(source.mimeType, source.dimensions);
235
- return [
236
- `╭${"─".repeat(innerWidth)}╮`,
237
- `│${padLine(this.fallbackColor(label), innerWidth)}│`,
238
- `╰${"─".repeat(innerWidth)}╯`,
239
- ];
240
- });
241
- for (let line = 0; line < 3; line += 1) {
242
- lines.push(cards.map((card) => card[line]).join(" "));
164
+ private build(): Container {
165
+ const kitty = getCapabilities().images === "kitty";
166
+ const container = new Container();
167
+ for (const source of this.sources) {
168
+ if (kitty && !source.kittyBase64) {
169
+ const label = imageFallback(source.mimeType, source.dimensions, source.filePath);
170
+ container.addChild(new Text(this.fallbackColor(label), 1, 0));
171
+ continue;
243
172
  }
173
+ container.addChild(new Image(
174
+ kitty ? source.kittyBase64! : source.base64,
175
+ kitty ? "image/png" : source.mimeType,
176
+ { fallbackColor: this.fallbackColor },
177
+ {
178
+ maxWidthCells: PREVIEW_MAX_WIDTH_CELLS,
179
+ maxHeightCells: PREVIEW_MAX_HEIGHT_CELLS,
180
+ filename: source.filePath,
181
+ },
182
+ source.dimensions,
183
+ ));
244
184
  }
245
-
246
- return lines;
247
- }
248
-
249
- private requestGallery(layout: GalleryLayout, signature: string): void {
250
- if (this.pendingSignature === signature) return;
251
- this.pendingSignature = signature;
252
- const generation = ++this.buildGeneration;
253
-
254
- void buildGallery(this.sources, layout).then((data) => {
255
- if (generation !== this.buildGeneration) return;
256
- this.gallery = {
257
- signature,
258
- image: new Image(data.toString("base64"), "image/png", {
259
- fallbackColor: this.fallbackColor,
260
- }, {
261
- maxWidthCells: layout.displayWidth,
262
- maxHeightCells: layout.displayHeight,
263
- }),
264
- };
265
- this.pendingSignature = undefined;
266
- this.tui.requestRender();
267
- }).catch(() => {
268
- if (generation !== this.buildGeneration) return;
269
- this.pendingSignature = undefined;
270
- this.tui.requestRender();
271
- });
185
+ return container;
272
186
  }
273
187
 
274
188
  render(width: number): string[] {
275
189
  if (this.sources.length === 0) return [];
276
- if (!getCapabilities().images) return this.renderFallback(width);
277
-
278
- const layout = galleryLayout(width, this.sources.length);
279
- const signature = `${this.sources.map((source) => source.filePath).join("\0")}\0${layout.columns}`;
280
- if (this.gallery?.signature === signature) return this.gallery.image.render(width);
281
-
282
- this.requestGallery(layout, signature);
283
- return this.renderFallback(width);
190
+ const key = [
191
+ getCapabilities().images ?? "none",
192
+ ...this.sources.map((source) => `${source.filePath} ${source.kittyBase64 ? "png" : "raw"}`),
193
+ ].join("\0");
194
+ if (!this.container || this.containerKey !== key) {
195
+ this.container = this.build();
196
+ this.containerKey = key;
197
+ }
198
+ return this.container.render(Math.max(1, width));
284
199
  }
285
200
 
286
201
  invalidate(): void {
287
- this.gallery?.image.invalidate();
202
+ this.container?.invalidate();
288
203
  }
289
204
 
290
205
  dispose(): void {
291
- this.buildGeneration += 1;
292
- this.gallery?.image.invalidate();
293
- this.gallery = undefined;
206
+ this.container = undefined;
207
+ this.containerKey = undefined;
294
208
  }
295
209
  }
296
210
 
@@ -301,8 +215,8 @@ function renderPreviewWidget(ctx: ExtensionContext, sources: PreviewSource[]): v
301
215
  }
302
216
 
303
217
  const snapshot = [...sources];
304
- ctx.ui.setWidget(WIDGET_KEY, (tui, theme) =>
305
- new ImagePreviewGallery(tui, snapshot, (text) => theme.fg("muted", text)));
218
+ ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) =>
219
+ new ClipboardPreviewGallery(snapshot, (text) => theme.fg("muted", text)));
306
220
  }
307
221
 
308
222
  export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
@@ -314,6 +228,7 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
314
228
  let generation = 0;
315
229
  let activeContext: ExtensionContext | undefined;
316
230
  let sources = new Map<string, PreviewSource>();
231
+ let trackedFiles = new Set<string>();
317
232
  let syncInFlight = false;
318
233
  let syncAgain = false;
319
234
 
@@ -323,6 +238,7 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
323
238
  timer = undefined;
324
239
  activeContext = undefined;
325
240
  sources = new Map();
241
+ trackedFiles = new Set();
326
242
  syncInFlight = false;
327
243
  syncAgain = false;
328
244
  if (ctx?.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
@@ -349,12 +265,17 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
349
265
 
350
266
  const additions = await Promise.all(paths
351
267
  .filter((path) => !sources.has(path))
352
- .map(async (path) => [path, await readPreviewSource(path)] as const));
268
+ .map(async (path) => {
269
+ const source = await readPreviewSource(path);
270
+ if (source) await prepareKittyVariant(source);
271
+ return [path, source] as const;
272
+ }));
353
273
  if (expectedGeneration !== generation || ctx !== activeContext) return;
354
274
 
355
275
  for (const [path, source] of additions) {
356
276
  if (source && !sources.has(path)) {
357
277
  sources.set(path, source);
278
+ trackedFiles.add(path);
358
279
  changed = true;
359
280
  }
360
281
  }
@@ -393,8 +314,10 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
393
314
  timer.unref?.();
394
315
  });
395
316
 
396
- pi.on("session_shutdown", (_event, ctx) => {
317
+ pi.on("session_shutdown", async (_event, ctx) => {
318
+ const tracked = [...trackedFiles];
397
319
  stop(ctx);
320
+ await Promise.all(tracked.map(deleteClipboardImage));
398
321
  });
399
322
  };
400
323
  }
package/src/png.ts DELETED
@@ -1,214 +0,0 @@
1
- import { deflateSync, inflateSync } from "node:zlib";
2
-
3
- /** Straight-alpha RGBA8 raster. */
4
- export interface RgbaImage {
5
- width: number;
6
- height: number;
7
- data: Uint8Array;
8
- }
9
-
10
- const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
11
- const CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
12
-
13
- const CRC_TABLE = (() => {
14
- const table = new Uint32Array(256);
15
- for (let n = 0; n < 256; n += 1) {
16
- let c = n;
17
- for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
18
- table[n] = c >>> 0;
19
- }
20
- return table;
21
- })();
22
-
23
- function crc32(...parts: Uint8Array[]): number {
24
- let crc = 0xffffffff;
25
- for (const part of parts) {
26
- for (const byte of part) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
27
- }
28
- return (crc ^ 0xffffffff) >>> 0;
29
- }
30
-
31
- function paeth(a: number, b: number, c: number): number {
32
- const p = a + b - c;
33
- const pa = Math.abs(p - a);
34
- const pb = Math.abs(p - b);
35
- const pc = Math.abs(p - c);
36
- if (pa <= pb && pa <= pc) return a;
37
- return pb <= pc ? b : c;
38
- }
39
-
40
- function unfilter(raw: Uint8Array, stride: number, height: number, bytesPerPixel: number): Uint8Array | null {
41
- const out = new Uint8Array(stride * height);
42
- let offset = 0;
43
- for (let y = 0; y < height; y += 1) {
44
- const filter = raw[offset];
45
- offset += 1;
46
- const row = out.subarray(y * stride, (y + 1) * stride);
47
- const prev = y > 0 ? out.subarray((y - 1) * stride, y * stride) : undefined;
48
- for (let x = 0; x < stride; x += 1) {
49
- const value = raw[offset + x]!;
50
- const left = x >= bytesPerPixel ? row[x - bytesPerPixel]! : 0;
51
- const up = prev ? prev[x]! : 0;
52
- const upLeft = prev && x >= bytesPerPixel ? prev[x - bytesPerPixel]! : 0;
53
- let decoded: number;
54
- switch (filter) {
55
- case 0: decoded = value; break;
56
- case 1: decoded = value + left; break;
57
- case 2: decoded = value + up; break;
58
- case 3: decoded = value + ((left + up) >> 1); break;
59
- case 4: decoded = value + paeth(left, up, upLeft); break;
60
- default: return null;
61
- }
62
- row[x] = decoded & 0xff;
63
- }
64
- offset += stride;
65
- }
66
- return out;
67
- }
68
-
69
- /**
70
- * Decode a non-interlaced PNG (any color type, bit depths 1-16) into RGBA8.
71
- * Returns null for anything it cannot represent so callers can fall back.
72
- */
73
- export function decodePng(bytes: Uint8Array): RgbaImage | null {
74
- if (bytes.length < 8 || SIGNATURE.some((byte, index) => bytes[index] !== byte)) return null;
75
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
76
-
77
- let width = 0;
78
- let height = 0;
79
- let bitDepth = 0;
80
- let colorType = 0;
81
- let interlace = 0;
82
- let palette: Uint8Array | undefined;
83
- let transparency: Uint8Array | undefined;
84
- const idat: Uint8Array[] = [];
85
-
86
- let offset = 8;
87
- while (offset + 8 <= bytes.length) {
88
- const length = view.getUint32(offset);
89
- const type = String.fromCharCode(bytes[offset + 4]!, bytes[offset + 5]!, bytes[offset + 6]!, bytes[offset + 7]!);
90
- const start = offset + 8;
91
- const end = start + length;
92
- if (end + 4 > bytes.length) return null;
93
- const chunk = bytes.subarray(start, end);
94
- if (type === "IHDR") {
95
- width = view.getUint32(start);
96
- height = view.getUint32(start + 4);
97
- bitDepth = bytes[start + 8]!;
98
- colorType = bytes[start + 9]!;
99
- interlace = bytes[start + 12]!;
100
- } else if (type === "PLTE") {
101
- palette = chunk;
102
- } else if (type === "tRNS") {
103
- transparency = chunk;
104
- } else if (type === "IDAT") {
105
- idat.push(chunk);
106
- } else if (type === "IEND") {
107
- break;
108
- }
109
- offset = end + 4;
110
- }
111
-
112
- const channels = CHANNELS_BY_COLOR_TYPE[colorType];
113
- if (!width || !height || interlace !== 0 || idat.length === 0 || !channels) return null;
114
- if (![1, 2, 4, 8, 16].includes(bitDepth)) return null;
115
- if (colorType === 3 && !palette) return null;
116
-
117
- const bitsPerPixel = channels * bitDepth;
118
- const bytesPerPixel = Math.max(1, bitsPerPixel >> 3);
119
- const stride = Math.ceil((width * bitsPerPixel) / 8);
120
-
121
- let raw: Uint8Array;
122
- try {
123
- raw = inflateSync(Buffer.concat(idat));
124
- } catch {
125
- return null;
126
- }
127
- if (raw.length < (stride + 1) * height) return null;
128
- const scanlines = unfilter(raw, stride, height, bytesPerPixel);
129
- if (!scanlines) return null;
130
-
131
- const maxSample = (1 << bitDepth) - 1;
132
- const readSample = (row: Uint8Array, index: number): number => {
133
- if (bitDepth === 8) return row[index]!;
134
- if (bitDepth === 16) return row[index * 2]!;
135
- const bitOffset = index * bitDepth;
136
- const byte = row[bitOffset >> 3]!;
137
- return (byte >> (8 - bitDepth - (bitOffset & 7))) & maxSample;
138
- };
139
- const scale = (sample: number): number => (bitDepth === 8 || bitDepth === 16 ? sample : Math.round((sample * 255) / maxSample));
140
-
141
- const data = new Uint8Array(width * height * 4);
142
- for (let y = 0; y < height; y += 1) {
143
- const row = scanlines.subarray(y * stride, (y + 1) * stride);
144
- for (let x = 0; x < width; x += 1) {
145
- const out = (y * width + x) * 4;
146
- const base = x * channels;
147
- switch (colorType) {
148
- case 0: {
149
- const gray = scale(readSample(row, base));
150
- data[out] = gray; data[out + 1] = gray; data[out + 2] = gray; data[out + 3] = 255;
151
- break;
152
- }
153
- case 2:
154
- data[out] = scale(readSample(row, base));
155
- data[out + 1] = scale(readSample(row, base + 1));
156
- data[out + 2] = scale(readSample(row, base + 2));
157
- data[out + 3] = 255;
158
- break;
159
- case 3: {
160
- const index = readSample(row, base);
161
- data[out] = palette![index * 3] ?? 0;
162
- data[out + 1] = palette![index * 3 + 1] ?? 0;
163
- data[out + 2] = palette![index * 3 + 2] ?? 0;
164
- data[out + 3] = transparency?.[index] ?? 255;
165
- break;
166
- }
167
- case 4: {
168
- const gray = scale(readSample(row, base));
169
- data[out] = gray; data[out + 1] = gray; data[out + 2] = gray;
170
- data[out + 3] = scale(readSample(row, base + 1));
171
- break;
172
- }
173
- default:
174
- data[out] = scale(readSample(row, base));
175
- data[out + 1] = scale(readSample(row, base + 1));
176
- data[out + 2] = scale(readSample(row, base + 2));
177
- data[out + 3] = scale(readSample(row, base + 3));
178
- }
179
- }
180
- }
181
-
182
- return { width, height, data };
183
- }
184
-
185
- function chunk(type: string, payload: Uint8Array): Buffer {
186
- const header = Buffer.alloc(8);
187
- header.writeUInt32BE(payload.length, 0);
188
- header.write(type, 4, "ascii");
189
- const crc = Buffer.alloc(4);
190
- crc.writeUInt32BE(crc32(header.subarray(4), payload), 0);
191
- return Buffer.concat([header, payload, crc]);
192
- }
193
-
194
- /** Encode an RGBA8 raster as a non-interlaced 8-bit RGBA PNG. */
195
- export function encodePng(image: RgbaImage): Buffer {
196
- const stride = image.width * 4;
197
- const raw = Buffer.alloc((stride + 1) * image.height);
198
- for (let y = 0; y < image.height; y += 1) {
199
- raw[y * (stride + 1)] = 0;
200
- raw.set(image.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
201
- }
202
- const ihdr = Buffer.alloc(13);
203
- ihdr.writeUInt32BE(image.width, 0);
204
- ihdr.writeUInt32BE(image.height, 4);
205
- ihdr[8] = 8; // bit depth
206
- ihdr[9] = 6; // RGBA
207
- ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
208
- return Buffer.concat([
209
- Buffer.from(SIGNATURE),
210
- chunk("IHDR", ihdr),
211
- chunk("IDAT", deflateSync(raw)),
212
- chunk("IEND", new Uint8Array(0)),
213
- ]);
214
- }
package/src/raster.ts DELETED
@@ -1,116 +0,0 @@
1
- import type { RgbaImage } from "./png.ts";
2
-
3
- export type Rgba = readonly [r: number, g: number, b: number, a: number];
4
-
5
- export interface Rect {
6
- x: number;
7
- y: number;
8
- width: number;
9
- height: number;
10
- }
11
-
12
- export function createImage(width: number, height: number): RgbaImage {
13
- return { width, height, data: new Uint8Array(width * height * 4) };
14
- }
15
-
16
- /** Box-filter resample (area average when shrinking, nearest when enlarging). */
17
- export function resample(image: RgbaImage, width: number, height: number): RgbaImage {
18
- if (width === image.width && height === image.height) return image;
19
- const out = createImage(width, height);
20
- const scaleX = image.width / width;
21
- const scaleY = image.height / height;
22
- for (let y = 0; y < height; y += 1) {
23
- const y0 = Math.floor(y * scaleY);
24
- const y1 = Math.max(y0 + 1, Math.min(image.height, Math.ceil((y + 1) * scaleY)));
25
- for (let x = 0; x < width; x += 1) {
26
- const x0 = Math.floor(x * scaleX);
27
- const x1 = Math.max(x0 + 1, Math.min(image.width, Math.ceil((x + 1) * scaleX)));
28
- let r = 0; let g = 0; let b = 0; let a = 0; let count = 0;
29
- for (let sy = y0; sy < y1; sy += 1) {
30
- for (let sx = x0; sx < x1; sx += 1) {
31
- const i = (sy * image.width + sx) * 4;
32
- const alpha = image.data[i + 3]!;
33
- r += image.data[i]! * alpha;
34
- g += image.data[i + 1]! * alpha;
35
- b += image.data[i + 2]! * alpha;
36
- a += alpha;
37
- count += 1;
38
- }
39
- }
40
- const o = (y * width + x) * 4;
41
- if (a > 0) {
42
- out.data[o] = Math.round(r / a);
43
- out.data[o + 1] = Math.round(g / a);
44
- out.data[o + 2] = Math.round(b / a);
45
- }
46
- out.data[o + 3] = Math.round(a / count);
47
- }
48
- }
49
- return out;
50
- }
51
-
52
- /** Scale to fit inside the box while preserving aspect ratio (enlarging allowed). */
53
- export function fitWithin(image: RgbaImage, maxWidth: number, maxHeight: number): RgbaImage {
54
- const scale = Math.min(maxWidth / image.width, maxHeight / image.height);
55
- const width = Math.max(1, Math.round(image.width * scale));
56
- const height = Math.max(1, Math.round(image.height * scale));
57
- return resample(image, width, height);
58
- }
59
-
60
- /** Anti-aliased coverage (0..1) of the pixel at (px, py) by a rounded rectangle. */
61
- export function roundedRectCoverage(px: number, py: number, rect: Rect, radius: number): number {
62
- const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
63
- const dx = Math.abs(px + 0.5 - (rect.x + rect.width / 2)) - (rect.width / 2 - r);
64
- const dy = Math.abs(py + 0.5 - (rect.y + rect.height / 2)) - (rect.height / 2 - r);
65
- const distance = Math.hypot(Math.max(dx, 0), Math.max(dy, 0)) + Math.min(Math.max(dx, dy), 0) - r;
66
- return Math.min(1, Math.max(0, 0.5 - distance));
67
- }
68
-
69
- function blendPixel(canvas: RgbaImage, x: number, y: number, color: Rgba, coverage: number): void {
70
- if (coverage <= 0 || x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return;
71
- const i = (y * canvas.width + x) * 4;
72
- const sa = (color[3] / 255) * coverage;
73
- if (sa <= 0) return;
74
- const da = canvas.data[i + 3]! / 255;
75
- const outA = sa + da * (1 - sa);
76
- for (let c = 0; c < 3; c += 1) {
77
- canvas.data[i + c] = Math.round((color[c]! * sa + canvas.data[i + c]! * da * (1 - sa)) / outA);
78
- }
79
- canvas.data[i + 3] = Math.round(outA * 255);
80
- }
81
-
82
- function forEachPixel(rect: Rect, canvas: RgbaImage, visit: (x: number, y: number) => void): void {
83
- const x0 = Math.max(0, Math.floor(rect.x));
84
- const y0 = Math.max(0, Math.floor(rect.y));
85
- const x1 = Math.min(canvas.width, Math.ceil(rect.x + rect.width));
86
- const y1 = Math.min(canvas.height, Math.ceil(rect.y + rect.height));
87
- for (let y = y0; y < y1; y += 1) {
88
- for (let x = x0; x < x1; x += 1) visit(x, y);
89
- }
90
- }
91
-
92
- export function fillRoundedRect(canvas: RgbaImage, rect: Rect, radius: number, color: Rgba): void {
93
- forEachPixel(rect, canvas, (x, y) => blendPixel(canvas, x, y, color, roundedRectCoverage(x, y, rect, radius)));
94
- }
95
-
96
- /** Stroke drawn inside `rect`, `width` pixels wide, following its rounded outline. */
97
- export function strokeRoundedRect(canvas: RgbaImage, rect: Rect, radius: number, width: number, color: Rgba): void {
98
- const inner: Rect = { x: rect.x + width, y: rect.y + width, width: rect.width - width * 2, height: rect.height - width * 2 };
99
- forEachPixel(rect, canvas, (x, y) => {
100
- const coverage = roundedRectCoverage(x, y, rect, radius) - roundedRectCoverage(x, y, inner, radius - width);
101
- blendPixel(canvas, x, y, color, coverage);
102
- });
103
- }
104
-
105
- /** Source-over blit; when `mask` is given, source alpha is clipped to that rounded rectangle. */
106
- export function blit(canvas: RgbaImage, image: RgbaImage, left: number, top: number, mask?: { rect: Rect; radius: number }): void {
107
- for (let y = 0; y < image.height; y += 1) {
108
- for (let x = 0; x < image.width; x += 1) {
109
- const cx = left + x;
110
- const cy = top + y;
111
- const coverage = mask ? roundedRectCoverage(cx, cy, mask.rect, mask.radius) : 1;
112
- const i = (y * image.width + x) * 4;
113
- blendPixel(canvas, cx, cy, [image.data[i]!, image.data[i + 1]!, image.data[i + 2]!, image.data[i + 3]!], coverage);
114
- }
115
- }
116
- }