@pi-archimedes/image-paste 2.12.0 → 2.13.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/README.md +5 -4
- package/package.json +5 -4
- package/src/index.ts +7 -4
- package/src/preview-setting.test.ts +164 -0
- package/src/settings.ts +33 -0
package/README.md
CHANGED
|
@@ -18,13 +18,13 @@ Or the full suite instead:
|
|
|
18
18
|
pi install npm:pi-archimedes
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0. If you need Pi, [its quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md) starts with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`. After installing Pi, choose one installation command above, then `cd` into your project, run `pi`, and use `/login` and `/model` inside the session. The [setup section](https://github.com/
|
|
21
|
+
New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0. If you need Pi, [its quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md) starts with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`. After installing Pi, choose one installation command above, then `cd` into your project, run `pi`, and use `/login` and `/model` inside the session. The [setup section](https://github.com/archimedes-codes/pi-archimedes#setup) covers it end to end. `/reload` picks the extension up in a running session.
|
|
22
22
|
|
|
23
23
|
## How it works
|
|
24
24
|
|
|
25
25
|
- **Markers on paste** — pasting inserts a visible `[Image #1]` placeholder at the cursor; each paste gets the next number.
|
|
26
26
|
- **Attachment on submit** — when you send the message, your text is scanned for markers and the matching queued images are attached to it. Delete a marker and its image is not attached — the text is the source of truth.
|
|
27
|
-
- **Inline preview** — a preview of the attached images is sent and rendered in the TUI. Previews appear where your terminal renders inline images; on a terminal that can't show them, the image is still attached — only the preview falls back.
|
|
27
|
+
- **Inline preview** — a preview of the attached images is sent and rendered in the TUI. Previews appear where your terminal renders inline images; on a terminal that can't show them, the image is still attached — only the preview falls back. Turn it off with `/archimedes` → Image Paste Preview, or `archimedes.imagePaste.preview: false`; images still attach.
|
|
28
28
|
- **Same key, wider scope** — the paste key is Pi's own (`app.clipboard.pasteImage`), and taking it means covering everything it did, so with no image on the clipboard the handler falls back to what Pi does: copied **file paths** are inserted at the cursor with Pi's own spacing, and otherwise the clipboard **text** is inserted. One keypress produces one outcome, in that order: image, then paths, then text. Nothing on the clipboard leaves the draft untouched and says nothing, exactly as Pi does.
|
|
29
29
|
- **Size guard** — 20 MiB **per image**, rejected with a clear message before it wastes context or hangs the submit.
|
|
30
30
|
- **Bounded reads** — every clipboard read has a deadline (the same budget Pi gives its own clipboard commands), so a helper that stops answering — an X11/Wayland session that went unresponsive, say — reports `Clipboard paste failed: … timed out` instead of silently taking the paste key out of service for the rest of the session.
|
|
@@ -66,12 +66,13 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
|
|
|
66
66
|
|---------|------|---------|-------------|
|
|
67
67
|
| `enabled` | bool | `true` | On/off for the extension, via `/plugins` (suite-managed) |
|
|
68
68
|
| `suppressNativePaste` | bool | `true` | `false` gives the paste key back to Pi: the suite's `app.clipboard.pasteImage` entry is removed from `~/.pi/agent/keybindings.json` (only that entry, only if the file holds nothing else but the suite's own `"$schema"` line) **and** no paste shortcut is registered. It also keeps the first-run offer silent — there is nothing to unbind — without spending `keybindingsPromptDone`, so turning it back on still brings the offer. You get Pi's paste and lose image attach / queue / preview on those keys. Applies at the next `/new` or `/reload`. Anything that is not the strict boolean `false` — `"false"`, `0` — keeps the default. |
|
|
69
|
+
| `preview` | bool | `true` | `false` stops the inline preview of attached images on submit. Images still attach. Also in `/archimedes` → Image Paste Preview. Read on every submit, so no `/reload` needed. Anything that is not the strict boolean `false` keeps the default. |
|
|
69
70
|
| `keybindingsPromptDone` | bool | `false` | Set true once the first-run keybindings.json offer has been answered (accept or decline); delete to re-open the offer |
|
|
70
71
|
|
|
71
72
|
In the suite on/off is managed by the suite: toggle via `/plugins` (`archimedes.imagePaste.enabled`, default on).
|
|
72
73
|
|
|
73
74
|
## Part of the suite
|
|
74
75
|
|
|
75
|
-
In [pi-archimedes](https://github.com/
|
|
76
|
+
In [pi-archimedes](https://github.com/archimedes-codes/pi-archimedes), image-paste works alongside the framed editor, the status bar, and the todo board; on/off is managed by the suite (`/plugins`, `archimedes.imagePaste.enabled`, default on).
|
|
76
77
|
|
|
77
|
-
← [Back to pi-archimedes](https://github.com/
|
|
78
|
+
← [Back to pi-archimedes](https://github.com/archimedes-codes/pi-archimedes)
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/image-paste",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
|
-
"url": "https://github.com/
|
|
6
|
+
"url": "https://github.com/archimedes-codes/pi-archimedes.git"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"keywords": [
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
"main": "./src/index.ts",
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./src/index.ts",
|
|
19
|
-
"./keybinding-offer": "./src/keybinding-offer.ts"
|
|
19
|
+
"./keybinding-offer": "./src/keybinding-offer.ts",
|
|
20
|
+
"./settings": "./src/settings.ts"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@pi-archimedes/core": "2.
|
|
23
|
+
"@pi-archimedes/core": "2.13.0"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
26
|
"@earendil-works/pi-coding-agent": ">=0.86.0",
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./clipboard.js";
|
|
16
16
|
import { runNativePasteRestore, suiteMayClaimPasteKey } from "./keybinding-offer.js";
|
|
17
17
|
import { registerImagePreview, sendPreviewMessage } from "./preview.js";
|
|
18
|
+
import { previewEnabled } from "./settings.js";
|
|
18
19
|
import type { ClipboardImage, PendingImage, ImageMarker } from "./types.js";
|
|
19
20
|
|
|
20
21
|
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
|
|
@@ -278,10 +279,12 @@ export function registerImagePaste(pi: ExtensionAPI): void {
|
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
// Send preview message for TUI display (images are invisible in user messages)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
if (previewEnabled()) {
|
|
283
|
+
try {
|
|
284
|
+
sendPreviewMessage(pi, imagesToAttach);
|
|
285
|
+
} catch {
|
|
286
|
+
// Preview is optional — don't fail the submit
|
|
287
|
+
}
|
|
285
288
|
}
|
|
286
289
|
|
|
287
290
|
return {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// `archimedes.imagePaste.preview: false` skips the inline preview message on
|
|
4
|
+
// submit; the image still attaches and the paste-time notify still fires.
|
|
5
|
+
//
|
|
6
|
+
// Settings: `core/settings-io` captures `join(getAgentDir(), "settings.json")`
|
|
7
|
+
// at module load, hence one scratch dir for the whole file, rewritten per case.
|
|
8
|
+
// Clipboard: the readers are mocked so a paste yields image bytes without
|
|
9
|
+
// shelling out.
|
|
10
|
+
|
|
11
|
+
const { tempDir, fs, join, mockReadClipboardImage } = vi.hoisted(() => {
|
|
12
|
+
const fs = require("node:fs");
|
|
13
|
+
const { join } = require("node:path");
|
|
14
|
+
const { tmpdir } = require("node:os");
|
|
15
|
+
const { randomUUID } = require("node:crypto");
|
|
16
|
+
const dir = join(tmpdir(), `image-paste-preview-setting-${randomUUID()}`);
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
return { tempDir: dir, fs, join, mockReadClipboardImage: vi.fn() };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
22
|
+
getAgentDir: () => tempDir,
|
|
23
|
+
convertToPng: () => undefined,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("./clipboard.js", () => ({
|
|
27
|
+
readClipboardImage: mockReadClipboardImage,
|
|
28
|
+
readClipboardFilePaths: vi.fn(async () => null),
|
|
29
|
+
readClipboardText: vi.fn(async () => null),
|
|
30
|
+
buildPathInsertion: vi.fn(),
|
|
31
|
+
clipboardTextForEditor: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const { registerImagePaste, initImagePasteSession, shutdownImagePaste } = await import("./index.js");
|
|
35
|
+
const { saveImagePastePreview } = await import("./settings.js");
|
|
36
|
+
|
|
37
|
+
const NAMESPACE = "archimedes.imagePaste";
|
|
38
|
+
const settingsPath = (): string => join(tempDir, "settings.json");
|
|
39
|
+
|
|
40
|
+
function writeSettings(imagePaste: Record<string, unknown> | undefined): void {
|
|
41
|
+
const settings: Record<string, unknown> = {};
|
|
42
|
+
if (imagePaste !== undefined) settings[NAMESPACE] = imagePaste;
|
|
43
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(settings), "utf-8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Handler = (...args: unknown[]) => unknown;
|
|
47
|
+
|
|
48
|
+
interface SubmitResult {
|
|
49
|
+
action: string;
|
|
50
|
+
images?: unknown[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Register once; `pasteAndSubmit()` then drives the same handlers each time. */
|
|
54
|
+
function registerSession() {
|
|
55
|
+
const pi = {
|
|
56
|
+
on: vi.fn(),
|
|
57
|
+
registerShortcut: vi.fn(),
|
|
58
|
+
registerMessageRenderer: vi.fn(),
|
|
59
|
+
registerCommand: vi.fn(),
|
|
60
|
+
sendMessage: vi.fn(),
|
|
61
|
+
};
|
|
62
|
+
const notify = vi.fn();
|
|
63
|
+
const ctx = { hasUI: true, ui: { notify, pasteToEditor: vi.fn() } };
|
|
64
|
+
|
|
65
|
+
registerImagePaste(pi as never);
|
|
66
|
+
initImagePasteSession(ctx as never);
|
|
67
|
+
|
|
68
|
+
const paste = pi.registerShortcut.mock.calls[0]![1].handler as Handler;
|
|
69
|
+
const input = pi.on.mock.calls.find((call) => call[0] === "input")![1] as Handler;
|
|
70
|
+
|
|
71
|
+
/** Paste one image through the shortcut and submit it. */
|
|
72
|
+
async function pasteAndSubmit(): Promise<SubmitResult> {
|
|
73
|
+
await paste(ctx);
|
|
74
|
+
return (await input({ text: "[Image #1] look", source: "interactive" })) as SubmitResult;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { pi, notify, pasteAndSubmit };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Register, paste one image through the shortcut, submit it; report what happened. */
|
|
81
|
+
async function pasteAndSubmit() {
|
|
82
|
+
const session = registerSession();
|
|
83
|
+
const result = await session.pasteAndSubmit();
|
|
84
|
+
return { pi: session.pi, notify: session.notify, result };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
fs.rmSync(settingsPath(), { force: true });
|
|
89
|
+
shutdownImagePaste();
|
|
90
|
+
mockReadClipboardImage.mockReset();
|
|
91
|
+
mockReadClipboardImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), mimeType: "image/png" });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("archimedes.imagePaste.preview", () => {
|
|
95
|
+
it("sends the preview when the setting is absent", async () => {
|
|
96
|
+
const { pi, result } = await pasteAndSubmit();
|
|
97
|
+
expect(result.action).toBe("transform");
|
|
98
|
+
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("sends the preview when preview is true", async () => {
|
|
102
|
+
writeSettings({ preview: true });
|
|
103
|
+
const { pi } = await pasteAndSubmit();
|
|
104
|
+
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("skips the preview when preview is false, but still notifies and attaches", async () => {
|
|
108
|
+
writeSettings({ preview: false });
|
|
109
|
+
const { pi, notify, result } = await pasteAndSubmit();
|
|
110
|
+
|
|
111
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
112
|
+
expect(notify).toHaveBeenCalledWith("Image attached from clipboard.", "info");
|
|
113
|
+
expect(result.action).toBe("transform");
|
|
114
|
+
expect(result.images).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("re-reads the setting on each submit through the same registered handler (no /reload)", async () => {
|
|
118
|
+
const session = registerSession();
|
|
119
|
+
|
|
120
|
+
const first = await session.pasteAndSubmit();
|
|
121
|
+
expect(session.pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(first.action).toBe("transform");
|
|
123
|
+
expect(first.images).toHaveLength(1);
|
|
124
|
+
|
|
125
|
+
writeSettings({ preview: false });
|
|
126
|
+
const second = await session.pasteAndSubmit();
|
|
127
|
+
expect(session.pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(second.action).toBe("transform");
|
|
129
|
+
expect(second.images).toHaveLength(1);
|
|
130
|
+
|
|
131
|
+
writeSettings({ preview: true });
|
|
132
|
+
const third = await session.pasteAndSubmit();
|
|
133
|
+
expect(session.pi.sendMessage).toHaveBeenCalledTimes(2);
|
|
134
|
+
expect(third.images).toHaveLength(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("treats a value that is not the boolean false as on", async () => {
|
|
138
|
+
for (const value of ["false", 0]) {
|
|
139
|
+
writeSettings({ preview: value });
|
|
140
|
+
shutdownImagePaste();
|
|
141
|
+
const { pi } = await pasteAndSubmit();
|
|
142
|
+
expect(pi.sendMessage, `preview=${JSON.stringify(value)}`).toHaveBeenCalledTimes(1);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("saveImagePastePreview (the /archimedes row)", () => {
|
|
148
|
+
it("merges preview into the namespace, keeps its other keys, and the next submit obeys it", async () => {
|
|
149
|
+
writeSettings({ enabled: true, suppressNativePaste: true, keybindingsPromptDone: true });
|
|
150
|
+
const session = registerSession();
|
|
151
|
+
|
|
152
|
+
saveImagePastePreview(false);
|
|
153
|
+
expect(JSON.parse(fs.readFileSync(settingsPath(), "utf-8"))[NAMESPACE]).toEqual({
|
|
154
|
+
enabled: true,
|
|
155
|
+
suppressNativePaste: true,
|
|
156
|
+
keybindingsPromptDone: true,
|
|
157
|
+
preview: false,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = await session.pasteAndSubmit();
|
|
161
|
+
expect(session.pi.sendMessage).not.toHaveBeenCalled();
|
|
162
|
+
expect(result.images).toHaveLength(1);
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
import { loadConfig, updateConfig } from "@pi-archimedes/core/settings-io";
|
|
4
|
+
|
|
5
|
+
// Kept free of the package's main entry (clipboard, renderer) so meta's
|
|
6
|
+
// /archimedes panel can import it without loading the lazily-loaded plugin.
|
|
7
|
+
|
|
8
|
+
const NAMESPACE = "archimedes.imagePaste";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `archimedes.imagePaste.preview`, default on. Only the strict boolean `false`
|
|
12
|
+
* disables it. Read per call so a settings change applies on the next submit.
|
|
13
|
+
*/
|
|
14
|
+
export function previewEnabled(): boolean {
|
|
15
|
+
return loadConfig<{ preview: boolean }>(NAMESPACE, { preview: true }).preview !== false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getImagePasteSettingsItems(): SettingItem[] {
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
id: "imagePastePreview",
|
|
22
|
+
label: "Image Paste Preview",
|
|
23
|
+
description: "Show an inline preview of pasted images after you submit",
|
|
24
|
+
currentValue: previewEnabled() ? "On" : "Off",
|
|
25
|
+
values: ["On", "Off"],
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Merges `preview` into the namespace; its other keys are kept. */
|
|
31
|
+
export function saveImagePastePreview(on: boolean): void {
|
|
32
|
+
updateConfig<Record<string, unknown>>(NAMESPACE, {}, (cfg) => ({ ...cfg, preview: on }));
|
|
33
|
+
}
|