@prjct.app/pi-clipboard 0.1.1 → 0.1.4

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,19 @@
1
+ ## [0.1.4](https://github.com/prjct-app/pi-clipboard/compare/v0.1.3...v0.1.4) (2026-09-10)
2
+
3
+ ### Bug Fixes
4
+
5
+ * delete previewed clipboard temp files on session shutdown ([a64a2b4](https://github.com/prjct-app/pi-clipboard/commit/a64a2b446565fc7867395186097a63b62779e3f4))
6
+ * render clipboard previews with Pi's native image components ([312295b](https://github.com/prjct-app/pi-clipboard/commit/312295b422d2c29bf5708be88c3254cb4bd17e45))
7
+
1
8
  # Changelog
2
9
 
10
+ ## 0.1.3
11
+
12
+ - Clarify the package description and add focused discovery keywords.
13
+ - Declare the cover image for the official Pi package gallery.
14
+
15
+ - Align repository, documentation, and cover URLs with the npm package name.
16
+
3
17
  ## 0.1.1
4
18
 
5
19
  - Add a dedicated cover to the GitHub and npm README.
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
@@ -1,8 +1,8 @@
1
1
  # pi-clipboard
2
2
 
3
- [![pi-clipboard — extension for PI Agent](https://raw.githubusercontent.com/prjct-app/pi-image-preview/main/docs/cover.png)](https://pi.dev)
3
+ [![pi-clipboard — extension for PI Agent](https://raw.githubusercontent.com/prjct-app/pi-clipboard/main/docs/cover.png)](https://pi.dev)
4
4
 
5
- Preview pasted images above the native Pi editor.
5
+ Preview pasted images in PI Agent before sending them, with inline thumbnails and a compact attachment gallery.
6
6
 
7
7
  `@prjct.app/pi-clipboard` · Clipboard image previews; one extension.
8
8
 
@@ -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. 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
@@ -41,17 +41,17 @@ pi remove npm:@prjct.app/pi-clipboard
41
41
 
42
42
  Use `pi config` to enable or disable individual resources. Use `pi config -l` for project settings and add `-l` to removal when you installed locally.
43
43
 
44
- To pin version 0.1.1, use `pi install npm:@prjct.app/pi-clipboard@0.1.1`. Pi skips pinned npm versions during package updates. For a Git installation, update or remove using the same `git:github.com/prjct-app/pi-image-preview` source instead of the npm source.
44
+ To pin version 0.1.3, use `pi install npm:@prjct.app/pi-clipboard@0.1.3`. Pi skips pinned npm versions during package updates. For a Git installation, update or remove using the same `git:github.com/prjct-app/pi-clipboard` source instead of the npm source.
45
45
 
46
46
  When switching from GitHub to npm, remove the Git installation first, then install the npm package and restart Pi.
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
@@ -4,7 +4,7 @@
4
4
 
5
5
  - npm name: `@prjct.app/pi-clipboard`.
6
6
  - Initial version: `0.1.0`.
7
- - Source repository: [prjct-app/pi-image-preview](https://github.com/prjct-app/pi-image-preview).
7
+ - Source repository: [prjct-app/pi-clipboard](https://github.com/prjct-app/pi-clipboard).
8
8
  - Tested host: Pi `0.85.1`; Node.js `22.19+`.
9
9
 
10
10
  The npm name and repository name may differ. Repository URLs remain unchanged. Existing runtime command names, event names, persisted entry types, and settings keys are unchanged by the package rename.
@@ -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. 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
 
@@ -52,3 +52,7 @@ These links are pinned to the tested Pi version rather than the moving main bran
52
52
  - [TUI: components, rendering, terminal widths, and image support](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/tui.md).
53
53
 
54
54
  The installed `@earendil-works/pi-coding-agent@0.85.1` package ships the same guides under `docs/`. The [current official guide](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) may describe changes beyond this tested baseline.
55
+
56
+ ## Discovery metadata
57
+
58
+ The `pi-package` keyword identifies this package for the official Pi gallery. Focused keywords describe its actual features. The `pi.image` field points to its public cover, following the [official gallery metadata format](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/packages.md#gallery-metadata).
@@ -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,20 +1,27 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-clipboard",
3
- "version": "0.1.1",
4
- "description": "Preview pasted images above the native Pi editor.",
3
+ "version": "0.1.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": [
7
7
  "pi-package",
8
8
  "pi",
9
+ "pi-agent",
10
+ "pi-coding-agent",
11
+ "pi-extension",
12
+ "ai-coding",
13
+ "developer-tools",
9
14
  "clipboard",
10
- "image",
11
- "preview",
12
- "terminal"
15
+ "image-preview",
16
+ "screenshots",
17
+ "terminal",
18
+ "tui"
13
19
  ],
14
20
  "pi": {
15
21
  "extensions": [
16
22
  "./index.ts"
17
- ]
23
+ ],
24
+ "image": "https://raw.githubusercontent.com/prjct-app/pi-clipboard/main/docs/cover.png"
18
25
  },
19
26
  "scripts": {
20
27
  "check": "tsc --noEmit",
@@ -38,11 +45,11 @@
38
45
  "license": "MIT",
39
46
  "repository": {
40
47
  "type": "git",
41
- "url": "https://github.com/prjct-app/pi-image-preview.git"
48
+ "url": "git+https://github.com/prjct-app/pi-clipboard.git"
42
49
  },
43
- "homepage": "https://github.com/prjct-app/pi-image-preview#readme",
50
+ "homepage": "https://github.com/prjct-app/pi-clipboard#readme",
44
51
  "bugs": {
45
- "url": "https://github.com/prjct-app/pi-image-preview/issues"
52
+ "url": "https://github.com/prjct-app/pi-clipboard/issues"
46
53
  },
47
54
  "publishConfig": {
48
55
  "access": "public"
@@ -54,6 +61,7 @@
54
61
  "LICENSE",
55
62
  "CONTRIBUTING.md",
56
63
  "CHANGELOG.md",
57
- "docs/package.md"
64
+ "docs/package.md",
65
+ "docs/releases.md"
58
66
  ]
59
67
  }
package/src/index.ts CHANGED
@@ -1,29 +1,24 @@
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
22
  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)";
28
23
  const CLIPBOARD_IMAGE_FILENAME = new RegExp(CLIPBOARD_IMAGE_NAME_SOURCE, "gi");
29
24
  const CLIPBOARD_IMAGE_BASENAME = new RegExp(`^${CLIPBOARD_IMAGE_NAME_SOURCE}$`, "i");
@@ -38,39 +33,16 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
38
33
  const WIDGET_KEY = "pi-image-preview";
39
34
  const DEFAULT_POLL_INTERVAL_MS = 150;
40
35
  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];
36
+ const PREVIEW_MAX_WIDTH_CELLS = 60;
37
+ const PREVIEW_MAX_HEIGHT_CELLS = 12;
56
38
 
57
39
  interface PreviewSource {
58
40
  filePath: string;
59
- data: Buffer;
41
+ base64: string;
60
42
  mimeType: string;
61
43
  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;
44
+ /** PNG variant for Kitty placements (f=100 only decodes PNG). Undefined while unconverted. */
45
+ kittyBase64?: string;
74
46
  }
75
47
 
76
48
  interface Scheduler {
@@ -122,11 +94,12 @@ async function readPreviewSource(filePath: string): Promise<PreviewSource | unde
122
94
  ) return undefined;
123
95
 
124
96
  const data = await handle.readFile();
97
+ const base64 = data.toString("base64");
125
98
  return {
126
99
  filePath,
127
- data,
100
+ base64,
128
101
  mimeType,
129
- dimensions: getImageDimensions(data.toString("base64"), mimeType) ?? { widthPx: 800, heightPx: 600 },
102
+ dimensions: getImageDimensions(base64, mimeType) ?? { widthPx: 800, heightPx: 600 },
130
103
  };
131
104
  } finally {
132
105
  await handle.close();
@@ -136,161 +109,99 @@ async function readPreviewSource(filePath: string): Promise<PreviewSource | unde
136
109
  }
137
110
  }
138
111
 
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;
112
+ /**
113
+ * Delete a clipboard image previously previewed by this session. Revalidates
114
+ * the same containment rules used for reads before unlinking, so only real
115
+ * Pi-owned temporary clipboard files are removed; anything else stays put.
116
+ */
117
+ async function deleteClipboardImage(filePath: string): Promise<void> {
118
+ if (dirname(resolve(filePath)) !== resolve(tmpdir())) return;
119
+ if (!CLIPBOARD_IMAGE_BASENAME.test(basename(filePath))) return;
162
120
  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");
121
+ const info = await lstat(filePath);
122
+ if (!info.isFile() || info.isSymbolicLink()) return;
123
+ await unlink(filePath);
169
124
  } catch {
170
- png = undefined;
125
+ // Already removed or unreadable; nothing to clean up.
171
126
  }
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
127
  }
176
128
 
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 });
129
+ /**
130
+ * Kitty graphics placements (f=100) only decode PNG, so non-PNG sources need a
131
+ * PNG variant. Conversion uses Pi's native `convertToPng` (bundled WASM, no
132
+ * external tools) and runs once per source, off the render path.
133
+ */
134
+ async function prepareKittyVariant(source: PreviewSource): Promise<void> {
135
+ if (source.mimeType === "image/png") {
136
+ source.kittyBase64 = source.base64;
137
+ return;
138
+ }
139
+ try {
140
+ const converted = await convertToPng(source.base64, source.mimeType);
141
+ source.kittyBase64 = converted?.data;
142
+ } catch {
143
+ source.kittyBase64 = undefined;
194
144
  }
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
145
  }
213
146
 
214
- class ImagePreviewGallery implements Component {
215
- private gallery?: RenderedGallery;
216
- private pendingSignature?: string;
217
- private buildGeneration = 0;
147
+ /**
148
+ * Renders one native Pi `Image` per clipboard attachment inside a `Container`.
149
+ * The `Image` component owns Kitty/iTerm2 placement and the `[Image: ...]`
150
+ * text fallback for terminals without inline-image support.
151
+ */
152
+ class ClipboardPreviewGallery implements Component {
153
+ private container?: Container;
154
+ private containerKey?: string;
218
155
 
219
156
  constructor(
220
- private readonly tui: TUI,
221
157
  private readonly sources: PreviewSource[],
222
158
  private readonly fallbackColor: (text: string) => string,
223
159
  ) {}
224
160
 
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(" "));
161
+ private build(): Container {
162
+ const kitty = getCapabilities().images === "kitty";
163
+ const container = new Container();
164
+ for (const source of this.sources) {
165
+ if (kitty && !source.kittyBase64) {
166
+ const label = imageFallback(source.mimeType, source.dimensions, source.filePath);
167
+ container.addChild(new Text(this.fallbackColor(label), 1, 0));
168
+ continue;
243
169
  }
170
+ container.addChild(new Image(
171
+ kitty ? source.kittyBase64! : source.base64,
172
+ kitty ? "image/png" : source.mimeType,
173
+ { fallbackColor: this.fallbackColor },
174
+ {
175
+ maxWidthCells: PREVIEW_MAX_WIDTH_CELLS,
176
+ maxHeightCells: PREVIEW_MAX_HEIGHT_CELLS,
177
+ filename: source.filePath,
178
+ },
179
+ source.dimensions,
180
+ ));
244
181
  }
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
- });
182
+ return container;
272
183
  }
273
184
 
274
185
  render(width: number): string[] {
275
186
  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);
187
+ const key = [
188
+ getCapabilities().images ?? "none",
189
+ ...this.sources.map((source) => `${source.filePath} ${source.kittyBase64 ? "png" : "raw"}`),
190
+ ].join("\0");
191
+ if (!this.container || this.containerKey !== key) {
192
+ this.container = this.build();
193
+ this.containerKey = key;
194
+ }
195
+ return this.container.render(Math.max(1, width));
284
196
  }
285
197
 
286
198
  invalidate(): void {
287
- this.gallery?.image.invalidate();
199
+ this.container?.invalidate();
288
200
  }
289
201
 
290
202
  dispose(): void {
291
- this.buildGeneration += 1;
292
- this.gallery?.image.invalidate();
293
- this.gallery = undefined;
203
+ this.container = undefined;
204
+ this.containerKey = undefined;
294
205
  }
295
206
  }
296
207
 
@@ -301,8 +212,8 @@ function renderPreviewWidget(ctx: ExtensionContext, sources: PreviewSource[]): v
301
212
  }
302
213
 
303
214
  const snapshot = [...sources];
304
- ctx.ui.setWidget(WIDGET_KEY, (tui, theme) =>
305
- new ImagePreviewGallery(tui, snapshot, (text) => theme.fg("muted", text)));
215
+ ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) =>
216
+ new ClipboardPreviewGallery(snapshot, (text) => theme.fg("muted", text)));
306
217
  }
307
218
 
308
219
  export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
@@ -314,6 +225,7 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
314
225
  let generation = 0;
315
226
  let activeContext: ExtensionContext | undefined;
316
227
  let sources = new Map<string, PreviewSource>();
228
+ let trackedFiles = new Set<string>();
317
229
  let syncInFlight = false;
318
230
  let syncAgain = false;
319
231
 
@@ -323,6 +235,7 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
323
235
  timer = undefined;
324
236
  activeContext = undefined;
325
237
  sources = new Map();
238
+ trackedFiles = new Set();
326
239
  syncInFlight = false;
327
240
  syncAgain = false;
328
241
  if (ctx?.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
@@ -349,12 +262,17 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
349
262
 
350
263
  const additions = await Promise.all(paths
351
264
  .filter((path) => !sources.has(path))
352
- .map(async (path) => [path, await readPreviewSource(path)] as const));
265
+ .map(async (path) => {
266
+ const source = await readPreviewSource(path);
267
+ if (source) await prepareKittyVariant(source);
268
+ return [path, source] as const;
269
+ }));
353
270
  if (expectedGeneration !== generation || ctx !== activeContext) return;
354
271
 
355
272
  for (const [path, source] of additions) {
356
273
  if (source && !sources.has(path)) {
357
274
  sources.set(path, source);
275
+ trackedFiles.add(path);
358
276
  changed = true;
359
277
  }
360
278
  }
@@ -393,8 +311,10 @@ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
393
311
  timer.unref?.();
394
312
  });
395
313
 
396
- pi.on("session_shutdown", (_event, ctx) => {
314
+ pi.on("session_shutdown", async (_event, ctx) => {
315
+ const tracked = [...trackedFiles];
397
316
  stop(ctx);
317
+ await Promise.all(tracked.map(deleteClipboardImage));
398
318
  });
399
319
  };
400
320
  }
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
- }