@prjct.app/pi-clipboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Set the npm package identity to `@prjct.app/pi-clipboard`.
6
+ - Clarify installation, project scope, updates, removal, usage, and limitations.
7
+ - Document resource discovery and dependencies against the official Pi 0.85.1 guides.
8
+ - Include contribution and package documentation in the release file list.
9
+
10
+ Initial npm release. The documentation and naming changes preserve the existing extension runtime behavior.
@@ -0,0 +1,14 @@
1
+ # Contributing
2
+
3
+ - Integration branch: `main`. Create a feature branch from `main`.
4
+ - Deliver changes through a pull request using `.github/pull_request_template.md`.
5
+ - Use English for code, documentation, tests, issues, and pull requests.
6
+ - Use strict TypeScript and Pi's public extension/TUI interfaces.
7
+ - Keep image data local and limit file access to Pi clipboard files in the system temporary directory.
8
+ - Keep tests offline and use temporary files rather than the real Pi configuration or clipboard.
9
+ - Run `npm run check` and `npm test` before review.
10
+ - Never push, open or merge a pull request, publish, or deploy without explicit authorization.
11
+
12
+ ## Package documentation
13
+
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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 prjct
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # pi-clipboard
2
+
3
+ Preview pasted images above the native Pi editor.
4
+
5
+ `@prjct.app/pi-clipboard` · Clipboard image previews; one extension.
6
+
7
+ ## Install
8
+
9
+ Requires Pi installed separately and Node.js **22.19 or later**. Compatibility is tested with **Pi 0.85.1**; newer versions are not yet verified. This is an independent community package.
10
+
11
+ Install with Pi's package manager:
12
+
13
+ ```sh
14
+ pi install npm:@prjct.app/pi-clipboard
15
+ ```
16
+
17
+ For project-only installation, add `-l`: `pi install -l npm:@prjct.app/pi-clipboard`. Restart Pi after installation. Do not install the same extension from both GitHub and npm: Pi treats those as different package identities.
18
+
19
+ ## Usage
20
+
21
+ 1. Start an interactive Pi session after installing the package.
22
+ 2. Paste an image using Pi's native clipboard flow.
23
+ 3. Inspect the preview above the editor, then submit your message normally.
24
+
25
+ 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.
26
+
27
+ 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.
28
+
29
+
30
+ ## Manage the package
31
+
32
+ For an npm installation:
33
+
34
+ ```sh
35
+ pi list
36
+ pi update npm:@prjct.app/pi-clipboard
37
+ pi remove npm:@prjct.app/pi-clipboard
38
+ ```
39
+
40
+ 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.
41
+
42
+ To pin version 0.1.0, use `pi install npm:@prjct.app/pi-clipboard@0.1.0`. 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.
43
+
44
+ When switching from GitHub to npm, remove the Git installation first, then install the npm package and restart Pi.
45
+
46
+ ## Troubleshooting
47
+
48
+ 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.
49
+
50
+ ## Package and API documentation
51
+
52
+ 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.
53
+
54
+ 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.
55
+
56
+ ## Development
57
+
58
+ From a repository checkout:
59
+
60
+ ```sh
61
+ npm ci --ignore-scripts
62
+ npm run check
63
+ npm test
64
+ npm run check:package
65
+ ```
66
+
67
+ Pi loads the TypeScript entry point directly; no build step is required. To try this checkout for one run, use `pi -e .`. Tests use isolated temporary state and do not call model APIs. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution rules and [CHANGELOG.md](CHANGELOG.md) for release notes.
68
+
69
+ ## License
70
+
71
+ [MIT](LICENSE).
@@ -0,0 +1,54 @@
1
+ # Package structure and compatibility
2
+
3
+ ## Identity
4
+
5
+ - npm name: `@prjct.app/pi-clipboard`.
6
+ - Initial version: `0.1.0`.
7
+ - Source repository: [prjct-app/pi-image-preview](https://github.com/prjct-app/pi-image-preview).
8
+ - Tested host: Pi `0.85.1`; Node.js `22.19+`.
9
+
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.
11
+
12
+ ## Resource manifest
13
+
14
+ ```json
15
+ {
16
+ "name": "@prjct.app/pi-clipboard",
17
+ "keywords": [
18
+ "pi-package"
19
+ ],
20
+ "pi": {
21
+ "extensions": [
22
+ "./index.ts"
23
+ ]
24
+ }
25
+ }
26
+ ```
27
+
28
+ The `pi-package` keyword makes the package discoverable. Manifest paths are relative to the package root. The extension entry point is shipped as TypeScript because Pi loads it directly. There is no CLI binary or JavaScript build artifact to install separately.
29
+
30
+ ## Dependencies
31
+
32
+ Pi-provided libraries imported by this package are declared in `peerDependencies` with `*`, as required by Pi's package guide. They are not bundled. Exact Pi 0.85.1 development dependencies establish the tested baseline; the peer wildcard is not a claim that every Pi release is supported.
33
+
34
+ Third-party runtime dependencies belong in `dependencies`. Companion extensions are installed separately only when communication uses Pi's event bus; this package does not import code from a separately installed companion. A package that directly imports another Pi package's resources must instead bundle it following the official guide.
35
+
36
+ ## Public interfaces
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.
39
+
40
+ ## Published contents
41
+
42
+ The `files` allowlist includes runtime resources, user documentation, and license files. Development tests, dependency folders, repository settings, and Git history are excluded. npm also includes `package.json` automatically. The npm lockfile remains in the repository for repeatable development installs.
43
+
44
+ Run `npm run check:package` to inspect the exact prospective tarball before release. Check that each manifest entry and each referenced local document exists in the packed file list. Only claim npm availability after verifying a successful registry publication.
45
+
46
+ ## Official references
47
+
48
+ These links are pinned to the tested Pi version rather than the moving main branch:
49
+
50
+ - [Packages: manifest, sources, dependencies, filtering, and deduplication](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/packages.md).
51
+ - [Extensions: lifecycle, commands, tools, messages, and UI APIs](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/extensions.md).
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
+
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.
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { clipboardImagePaths, createImagePreviewExtension, default } from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@prjct.app/pi-clipboard",
3
+ "version": "0.1.0",
4
+ "description": "Preview pasted images above the native Pi editor.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "clipboard",
10
+ "image",
11
+ "preview",
12
+ "terminal"
13
+ ],
14
+ "pi": {
15
+ "extensions": [
16
+ "./index.ts"
17
+ ]
18
+ },
19
+ "scripts": {
20
+ "check": "tsc --noEmit",
21
+ "test": "node --import tsx --test tests/*.test.ts",
22
+ "check:package": "npm pack --dry-run --ignore-scripts"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-coding-agent": "*",
29
+ "@earendil-works/pi-tui": "*"
30
+ },
31
+ "devDependencies": {
32
+ "@earendil-works/pi-coding-agent": "0.85.1",
33
+ "@earendil-works/pi-tui": "0.85.1",
34
+ "@types/node": "^22.19.0",
35
+ "tsx": "^4.20.0",
36
+ "typescript": "^5.9.3"
37
+ },
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/prjct-app/pi-image-preview.git"
42
+ },
43
+ "homepage": "https://github.com/prjct-app/pi-image-preview#readme",
44
+ "bugs": {
45
+ "url": "https://github.com/prjct-app/pi-image-preview/issues"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "files": [
51
+ "index.ts",
52
+ "src",
53
+ "README.md",
54
+ "LICENSE",
55
+ "CONTRIBUTING.md",
56
+ "CHANGELOG.md",
57
+ "docs/package.md"
58
+ ]
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,402 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, extname, resolve } from "node:path";
5
+
6
+ import {
7
+ convertToPng,
8
+ resizeImage,
9
+ type ExtensionAPI,
10
+ type ExtensionContext,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ getCapabilities,
14
+ getImageDimensions,
15
+ imageFallback,
16
+ Image,
17
+ truncateToWidth,
18
+ visibleWidth,
19
+ type Component,
20
+ type ImageDimensions,
21
+ type TUI,
22
+ } from "@earendil-works/pi-tui";
23
+
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)";
28
+ const CLIPBOARD_IMAGE_FILENAME = new RegExp(CLIPBOARD_IMAGE_NAME_SOURCE, "gi");
29
+ const CLIPBOARD_IMAGE_BASENAME = new RegExp(`^${CLIPBOARD_IMAGE_NAME_SOURCE}$`, "i");
30
+ const IMAGE_MIME_TYPES: Record<string, string> = {
31
+ ".gif": "image/gif",
32
+ ".jpeg": "image/jpeg",
33
+ ".jpg": "image/jpeg",
34
+ ".png": "image/png",
35
+ ".webp": "image/webp",
36
+ };
37
+
38
+ const WIDGET_KEY = "pi-image-preview";
39
+ const DEFAULT_POLL_INTERVAL_MS = 150;
40
+ 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];
56
+
57
+ interface PreviewSource {
58
+ filePath: string;
59
+ data: Buffer;
60
+ mimeType: string;
61
+ 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;
74
+ }
75
+
76
+ interface Scheduler {
77
+ setInterval(callback: () => void, intervalMs: number): ReturnType<typeof setInterval>;
78
+ clearInterval(handle: ReturnType<typeof setInterval>): void;
79
+ }
80
+
81
+ export interface ImagePreviewOptions {
82
+ pollIntervalMs?: number;
83
+ scheduler?: Scheduler;
84
+ }
85
+
86
+ const systemScheduler: Scheduler = {
87
+ setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
88
+ clearInterval: (handle) => clearInterval(handle),
89
+ };
90
+
91
+ /** Resolve only filenames produced by Pi's native clipboard flow. */
92
+ export function clipboardImagePaths(editorText: string): string[] {
93
+ const paths = new Set<string>();
94
+ const matcher = new RegExp(CLIPBOARD_IMAGE_FILENAME.source, CLIPBOARD_IMAGE_FILENAME.flags);
95
+ for (const match of editorText.matchAll(matcher)) {
96
+ const fileName = match[0];
97
+ const filePath = resolve(tmpdir(), fileName);
98
+ if (editorText.includes(filePath)) paths.add(filePath);
99
+ }
100
+ return [...paths];
101
+ }
102
+
103
+ async function readPreviewSource(filePath: string): Promise<PreviewSource | undefined> {
104
+ if (dirname(resolve(filePath)) !== resolve(tmpdir())) return undefined;
105
+ const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()];
106
+ if (!mimeType || !CLIPBOARD_IMAGE_BASENAME.test(basename(filePath))) return undefined;
107
+
108
+ try {
109
+ const before = await lstat(filePath);
110
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_PREVIEW_FILE_BYTES) return undefined;
111
+
112
+ const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
113
+ try {
114
+ const opened = await handle.stat();
115
+ const after = await lstat(filePath);
116
+ if (
117
+ !opened.isFile()
118
+ || after.isSymbolicLink()
119
+ || opened.size > MAX_PREVIEW_FILE_BYTES
120
+ || opened.dev !== after.dev
121
+ || opened.ino !== after.ino
122
+ ) return undefined;
123
+
124
+ const data = await handle.readFile();
125
+ return {
126
+ filePath,
127
+ data,
128
+ mimeType,
129
+ dimensions: getImageDimensions(data.toString("base64"), mimeType) ?? { widthPx: 800, heightPx: 600 },
130
+ };
131
+ } finally {
132
+ await handle.close();
133
+ }
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ }
138
+
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;
162
+ 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");
169
+ } catch {
170
+ png = undefined;
171
+ }
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
+ }
176
+
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 });
194
+ }
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
+ }
213
+
214
+ class ImagePreviewGallery implements Component {
215
+ private gallery?: RenderedGallery;
216
+ private pendingSignature?: string;
217
+ private buildGeneration = 0;
218
+
219
+ constructor(
220
+ private readonly tui: TUI,
221
+ private readonly sources: PreviewSource[],
222
+ private readonly fallbackColor: (text: string) => string,
223
+ ) {}
224
+
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(" "));
243
+ }
244
+ }
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
+ });
272
+ }
273
+
274
+ render(width: number): string[] {
275
+ 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);
284
+ }
285
+
286
+ invalidate(): void {
287
+ this.gallery?.image.invalidate();
288
+ }
289
+
290
+ dispose(): void {
291
+ this.buildGeneration += 1;
292
+ this.gallery?.image.invalidate();
293
+ this.gallery = undefined;
294
+ }
295
+ }
296
+
297
+ function renderPreviewWidget(ctx: ExtensionContext, sources: PreviewSource[]): void {
298
+ if (sources.length === 0) {
299
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
300
+ return;
301
+ }
302
+
303
+ const snapshot = [...sources];
304
+ ctx.ui.setWidget(WIDGET_KEY, (tui, theme) =>
305
+ new ImagePreviewGallery(tui, snapshot, (text) => theme.fg("muted", text)));
306
+ }
307
+
308
+ export function createImagePreviewExtension(options: ImagePreviewOptions = {}) {
309
+ const scheduler = options.scheduler ?? systemScheduler;
310
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
311
+
312
+ return function imagePreview(pi: ExtensionAPI): void {
313
+ let timer: ReturnType<typeof setInterval> | undefined;
314
+ let generation = 0;
315
+ let activeContext: ExtensionContext | undefined;
316
+ let sources = new Map<string, PreviewSource>();
317
+ let syncInFlight = false;
318
+ let syncAgain = false;
319
+
320
+ const stop = (ctx?: ExtensionContext) => {
321
+ generation += 1;
322
+ if (timer) scheduler.clearInterval(timer);
323
+ timer = undefined;
324
+ activeContext = undefined;
325
+ sources = new Map();
326
+ syncInFlight = false;
327
+ syncAgain = false;
328
+ if (ctx?.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
329
+ };
330
+
331
+ const syncOnce = async (ctx: ExtensionContext, expectedGeneration: number) => {
332
+ let editorText: string;
333
+ try {
334
+ editorText = ctx.ui.getEditorText();
335
+ } catch {
336
+ return;
337
+ }
338
+
339
+ const paths = clipboardImagePaths(editorText);
340
+ const pathSet = new Set(paths);
341
+ let changed = false;
342
+
343
+ for (const path of sources.keys()) {
344
+ if (!pathSet.has(path)) {
345
+ sources.delete(path);
346
+ changed = true;
347
+ }
348
+ }
349
+
350
+ const additions = await Promise.all(paths
351
+ .filter((path) => !sources.has(path))
352
+ .map(async (path) => [path, await readPreviewSource(path)] as const));
353
+ if (expectedGeneration !== generation || ctx !== activeContext) return;
354
+
355
+ for (const [path, source] of additions) {
356
+ if (source && !sources.has(path)) {
357
+ sources.set(path, source);
358
+ changed = true;
359
+ }
360
+ }
361
+
362
+ if (changed) renderPreviewWidget(ctx, [...sources.values()]);
363
+ };
364
+
365
+ const sync = async (ctx: ExtensionContext, expectedGeneration: number) => {
366
+ if (syncInFlight) {
367
+ syncAgain = true;
368
+ return;
369
+ }
370
+ syncInFlight = true;
371
+ try {
372
+ do {
373
+ syncAgain = false;
374
+ await syncOnce(ctx, expectedGeneration);
375
+ } while (syncAgain && expectedGeneration === generation && ctx === activeContext);
376
+ } finally {
377
+ syncInFlight = false;
378
+ }
379
+ };
380
+
381
+ pi.on("session_start", async (_event, ctx) => {
382
+ stop();
383
+ if (ctx.mode !== "tui") return;
384
+
385
+ activeContext = ctx;
386
+ const expectedGeneration = generation;
387
+ await sync(ctx, expectedGeneration);
388
+ if (expectedGeneration !== generation || ctx !== activeContext) return;
389
+
390
+ timer = scheduler.setInterval(() => {
391
+ void sync(ctx, expectedGeneration);
392
+ }, pollIntervalMs);
393
+ timer.unref?.();
394
+ });
395
+
396
+ pi.on("session_shutdown", (_event, ctx) => {
397
+ stop(ctx);
398
+ });
399
+ };
400
+ }
401
+
402
+ export default createImagePreviewExtension();
package/src/png.ts ADDED
@@ -0,0 +1,214 @@
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 ADDED
@@ -0,0 +1,116 @@
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
+ }