@saifymatteo/pi-extensions 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE.md +20 -0
- package/README.md +151 -2
- package/inventory.json +73 -0
- package/package.json +74 -4
- package/packages/pi-bg-bash/LICENSE +661 -0
- package/packages/pi-bg-bash/NOTICE.md +3 -0
- package/packages/pi-bg-bash/README.md +41 -0
- package/packages/pi-bg-bash/examples/settings.example.json +6 -0
- package/packages/pi-bg-bash/package.json +38 -0
- package/packages/pi-bg-bash/src/index.ts +969 -0
- package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
- package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
- package/packages/pi-claude-usage/LICENSE +661 -0
- package/packages/pi-claude-usage/NOTICE.md +3 -0
- package/packages/pi-claude-usage/README.md +27 -0
- package/packages/pi-claude-usage/package.json +35 -0
- package/packages/pi-claude-usage/src/index.ts +130 -0
- package/packages/pi-clipboard-image/LICENSE +661 -0
- package/packages/pi-clipboard-image/NOTICE.md +3 -0
- package/packages/pi-clipboard-image/README.md +27 -0
- package/packages/pi-clipboard-image/package.json +36 -0
- package/packages/pi-clipboard-image/src/index.ts +527 -0
- package/packages/pi-codeblock-borders/LICENSE +661 -0
- package/packages/pi-codeblock-borders/NOTICE.md +3 -0
- package/packages/pi-codeblock-borders/README.md +30 -0
- package/packages/pi-codeblock-borders/package.json +35 -0
- package/packages/pi-codeblock-borders/src/index.ts +302 -0
- package/packages/pi-codegraph/LICENSE +661 -0
- package/packages/pi-codegraph/NOTICE.md +5 -0
- package/packages/pi-codegraph/README.md +35 -0
- package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
- package/packages/pi-codegraph/package.json +37 -0
- package/packages/pi-codegraph/src/index.ts +573 -0
- package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
- package/packages/pi-compact-tools/LICENSE +661 -0
- package/packages/pi-compact-tools/NOTICE.md +3 -0
- package/packages/pi-compact-tools/README.md +27 -0
- package/packages/pi-compact-tools/package.json +36 -0
- package/packages/pi-compact-tools/src/index.ts +1161 -0
- package/packages/pi-decisions/LICENSE +661 -0
- package/packages/pi-decisions/NOTICE.md +3 -0
- package/packages/pi-decisions/README.md +37 -0
- package/packages/pi-decisions/examples/service-models.example.json +27 -0
- package/packages/pi-decisions/package.json +37 -0
- package/packages/pi-decisions/src/index.ts +364 -0
- package/packages/pi-decisions/src/lib/service-models.ts +95 -0
- package/packages/pi-decisions/test/service-models.test.mjs +71 -0
- package/packages/pi-load-pi-dotenv/LICENSE +661 -0
- package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
- package/packages/pi-load-pi-dotenv/README.md +27 -0
- package/packages/pi-load-pi-dotenv/package.json +32 -0
- package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
- package/packages/pi-model-go/LICENSE +661 -0
- package/packages/pi-model-go/NOTICE.md +5 -0
- package/packages/pi-model-go/README.md +33 -0
- package/packages/pi-model-go/package.json +36 -0
- package/packages/pi-model-go/src/index.ts +171 -0
- package/packages/pi-model-go/src/lib/dialog.ts +31 -0
- package/packages/pi-model-go/src/lib/model-go.ts +239 -0
- package/packages/pi-model-go/test/behavior.test.mjs +17 -0
- package/packages/pi-prestart-working/LICENSE +661 -0
- package/packages/pi-prestart-working/NOTICE.md +3 -0
- package/packages/pi-prestart-working/README.md +25 -0
- package/packages/pi-prestart-working/package.json +35 -0
- package/packages/pi-prestart-working/src/index.ts +86 -0
- package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
- package/packages/pi-root-guard/LICENSE +661 -0
- package/packages/pi-root-guard/NOTICE.md +3 -0
- package/packages/pi-root-guard/README.md +27 -0
- package/packages/pi-root-guard/package.json +32 -0
- package/packages/pi-root-guard/src/index.ts +202 -0
- package/packages/pi-session-finder/LICENSE +661 -0
- package/packages/pi-session-finder/NOTICE.md +3 -0
- package/packages/pi-session-finder/README.md +33 -0
- package/packages/pi-session-finder/package.json +36 -0
- package/packages/pi-session-finder/src/index.ts +306 -0
- package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
- package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
- package/packages/pi-session-id/LICENSE +661 -0
- package/packages/pi-session-id/NOTICE.md +3 -0
- package/packages/pi-session-id/README.md +31 -0
- package/packages/pi-session-id/package.json +35 -0
- package/packages/pi-session-id/src/index.ts +20 -0
- package/packages/pi-session-id/test/behavior.test.mjs +25 -0
- package/packages/pi-session-nav/LICENSE +661 -0
- package/packages/pi-session-nav/NOTICE.md +3 -0
- package/packages/pi-session-nav/README.md +32 -0
- package/packages/pi-session-nav/package.json +36 -0
- package/packages/pi-session-nav/src/index.ts +203 -0
- package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
- package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
- package/packages/pi-session-rename/LICENSE +661 -0
- package/packages/pi-session-rename/NOTICE.md +3 -0
- package/packages/pi-session-rename/README.md +31 -0
- package/packages/pi-session-rename/package.json +35 -0
- package/packages/pi-session-rename/src/index.ts +456 -0
- package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
- package/packages/pi-session-retention/LICENSE +661 -0
- package/packages/pi-session-retention/NOTICE.md +3 -0
- package/packages/pi-session-retention/README.md +62 -0
- package/packages/pi-session-retention/package.json +36 -0
- package/packages/pi-session-retention/src/README.md +42 -0
- package/packages/pi-session-retention/src/core.ts +333 -0
- package/packages/pi-session-retention/src/dialog.ts +118 -0
- package/packages/pi-session-retention/src/index.ts +311 -0
- package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
- package/packages/pi-session-retention/src/safety.ts +50 -0
- package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
- package/packages/pi-skill-usage-log/LICENSE +661 -0
- package/packages/pi-skill-usage-log/NOTICE.md +3 -0
- package/packages/pi-skill-usage-log/README.md +32 -0
- package/packages/pi-skill-usage-log/package.json +36 -0
- package/packages/pi-skill-usage-log/src/index.ts +316 -0
- package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
- package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
- package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
- package/packages/pi-tool-usage-log/LICENSE +661 -0
- package/packages/pi-tool-usage-log/NOTICE.md +5 -0
- package/packages/pi-tool-usage-log/README.md +32 -0
- package/packages/pi-tool-usage-log/package.json +36 -0
- package/packages/pi-tool-usage-log/src/index.ts +233 -0
- package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
- package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
- package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
- package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
- package/packages/pi-tts/LICENSE +661 -0
- package/packages/pi-tts/NOTICE.md +3 -0
- package/packages/pi-tts/README.md +37 -0
- package/packages/pi-tts/examples/service-models.example.json +27 -0
- package/packages/pi-tts/package.json +37 -0
- package/packages/pi-tts/src/index.ts +135 -0
- package/packages/pi-tts/src/lib/service-models.ts +95 -0
- package/packages/pi-tts/test/tts-output.test.mjs +47 -0
- package/packages/pi-tts/test/tts.test.mjs +57 -0
- package/packages/pi-user-image-previews/LICENSE +661 -0
- package/packages/pi-user-image-previews/NOTICE.md +3 -0
- package/packages/pi-user-image-previews/README.md +27 -0
- package/packages/pi-user-image-previews/package.json +36 -0
- package/packages/pi-user-image-previews/src/index.ts +168 -0
- package/packages/pi-vision/LICENSE +661 -0
- package/packages/pi-vision/NOTICE.md +5 -0
- package/packages/pi-vision/README.md +43 -0
- package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
- package/packages/pi-vision/package.json +38 -0
- package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
- package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
- package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
- package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
- package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
- package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
- package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
- package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
- package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
- package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
- package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
- package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
- package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
- package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
- package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
- package/packages/pi-vision/src/index.ts +728 -0
- package/packages/pi-vision/test/vision.test.mjs +30 -0
- package/packages/pi-wait/LICENSE +661 -0
- package/packages/pi-wait/NOTICE.md +3 -0
- package/packages/pi-wait/README.md +37 -0
- package/packages/pi-wait/package.json +37 -0
- package/packages/pi-wait/src/index.ts +88 -0
- package/packages/pi-wait/src/lib/async-work.ts +27 -0
- package/packages/pi-wait/test/wait.test.mjs +43 -0
- package/packages/pi-zvec/LICENSE +661 -0
- package/packages/pi-zvec/NOTICE.md +3 -0
- package/packages/pi-zvec/README.md +37 -0
- package/packages/pi-zvec/examples/zvec.example.json +5 -0
- package/packages/pi-zvec/package.json +37 -0
- package/packages/pi-zvec/src/index.ts +754 -0
- package/packages/pi-zvec/test/zvec.test.mjs +19 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Clipboard image attachment
|
|
2
|
+
|
|
3
|
+
Attaches clipboard images to the draft, including copied image files on Windows.
|
|
4
|
+
|
|
5
|
+
## Try locally
|
|
6
|
+
|
|
7
|
+
From the repository root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pi -e ./packages/pi-clipboard-image
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This uses your normal personal resources unless you isolate the agent home. Follow the [root README](../../README.md#isolated-sessions) to avoid loading an existing copy of the extension. Nothing is installed globally.
|
|
14
|
+
|
|
15
|
+
## Configuration and requirements
|
|
16
|
+
|
|
17
|
+
Registers Alt+V and Ctrl+Alt+V. Uses OS clipboard tools/native clipboard support and temporary image files. Windows/WSL use PowerShell; macOS and Linux use platform-specific clipboard mechanisms. See source for fallbacks; platform behavior needs manual verification.
|
|
18
|
+
|
|
19
|
+
## Compatibility and safety
|
|
20
|
+
|
|
21
|
+
Reads clipboard image/file data and places it in the draft. Sending the draft transmits attachments to the selected model. Requires interactive terminal support; terminal shortcuts may intercept the key.
|
|
22
|
+
|
|
23
|
+
Imported host APIs are declared as peer dependencies and supplied by the Pi host. A `*` peer range describes host resolution, not a guarantee of compatibility with every release. See root verification notes for what was actually exercised.
|
|
24
|
+
|
|
25
|
+
## Status
|
|
26
|
+
|
|
27
|
+
Part of the [pi-extensions](../../README.md) collection. No personal settings or credentials are included; see [NOTICE.md](NOTICE.md) for licensing and provenance.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saifymatteo/pi-clipboard-image",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Attaches clipboard images to the draft, including copied image files on Windows.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "AGPL-3.0-only",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"NOTICE.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./src/index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=24"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
26
|
+
"@earendil-works/pi-tui": "*"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/saifymatteo/pi-extensions.git",
|
|
34
|
+
"directory": "packages/pi-clipboard-image"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clipboard-image.ts — attach a clipboard image to the draft with Alt+V.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists instead of `@pi-archimedes/image-paste`: on Windows that
|
|
5
|
+
* package only ever reads a *bitmap* from the clipboard. Copying an image
|
|
6
|
+
* *file* in Explorer puts `FileDrop`/`FileContents` on the clipboard with no
|
|
7
|
+
* bitmap (`Clipboard.ContainsImage()` is False), so Alt+V answered
|
|
8
|
+
* "No image found in clipboard." forever. Windows Terminal then forwards
|
|
9
|
+
* Ctrl+V as a bracketed paste of the file path, which pi tries to read as a
|
|
10
|
+
* file — and guardrails blocks it when the file lives outside `allowedPaths`
|
|
11
|
+
* (e.g. a screenshot on another drive).
|
|
12
|
+
*
|
|
13
|
+
* This extension reads whatever the clipboard actually holds, on Windows and
|
|
14
|
+
* WSL via one PowerShell -STA call. It attaches image bytes through the `input`
|
|
15
|
+
* transform and appends the temp-file path to the submitted text so vision-tools
|
|
16
|
+
* CLIs can access the image. macOS uses pi-tui's bundled native helper; native
|
|
17
|
+
* Linux tries wl-paste/xclip first and falls back to that helper.
|
|
18
|
+
*
|
|
19
|
+
* Alt+V attach the clipboard image (bitmap or image file)
|
|
20
|
+
* Ctrl+Alt+V same
|
|
21
|
+
* Delete the `[Image #N]` marker before sending to drop that image.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
|
|
30
|
+
import { convertToPng, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { getNativeClipboard, type KeyId } from "@earendil-works/pi-tui";
|
|
32
|
+
|
|
33
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MiB, matches the rest of the image pipeline
|
|
34
|
+
const PS_TIMEOUT_MS = 8000;
|
|
35
|
+
const PS_MAX_STDOUT_BYTES = 40 * 1024 * 1024; // base64 of a >20 MiB image; the size guard rejects it after decode
|
|
36
|
+
const PS_ATTEMPTS = 2;
|
|
37
|
+
const PS_RETRY_DELAY_MS = 180;
|
|
38
|
+
const LINUX_LIST_TIMEOUT_MS = 1000;
|
|
39
|
+
const LINUX_READ_TIMEOUT_MS = 5000;
|
|
40
|
+
const COMMAND_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
41
|
+
const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp"] as const;
|
|
42
|
+
|
|
43
|
+
interface ClipboardImage {
|
|
44
|
+
bytes: Uint8Array;
|
|
45
|
+
mimeType: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PendingImage {
|
|
49
|
+
id: string;
|
|
50
|
+
base64: string;
|
|
51
|
+
path: string;
|
|
52
|
+
mimeType: string;
|
|
53
|
+
marker: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Windows reader (bitmap OR file drop) ────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const PS_SCRIPT = `
|
|
59
|
+
$ErrorActionPreference = 'Stop'
|
|
60
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
61
|
+
Add-Type -AssemblyName System.Drawing
|
|
62
|
+
|
|
63
|
+
$img = [System.Windows.Forms.Clipboard]::GetImage()
|
|
64
|
+
if ($null -ne $img) {
|
|
65
|
+
$ms = New-Object System.IO.MemoryStream
|
|
66
|
+
try {
|
|
67
|
+
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
68
|
+
Write-Output ('IMG:' + [System.Convert]::ToBase64String($ms.ToArray()))
|
|
69
|
+
} finally {
|
|
70
|
+
$ms.Dispose()
|
|
71
|
+
$img.Dispose()
|
|
72
|
+
}
|
|
73
|
+
exit 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if ([System.Windows.Forms.Clipboard]::ContainsFileDropList()) {
|
|
77
|
+
foreach ($f in [System.Windows.Forms.Clipboard]::GetFileDropList()) {
|
|
78
|
+
$ext = [System.IO.Path]::GetExtension($f).ToLowerInvariant()
|
|
79
|
+
if ('.png','.jpg','.jpeg','.webp','.gif','.bmp' -contains $ext) {
|
|
80
|
+
if (Test-Path -LiteralPath $f) {
|
|
81
|
+
$b = [System.IO.File]::ReadAllBytes($f)
|
|
82
|
+
Write-Output ('FILE:' + $ext + ':' + [System.Convert]::ToBase64String($b))
|
|
83
|
+
exit 0
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
$o = [System.Windows.Forms.Clipboard]::GetDataObject()
|
|
90
|
+
if ($null -ne $o -and $o.GetDataPresent('FileContents')) {
|
|
91
|
+
$s = $o.GetData('FileContents')
|
|
92
|
+
if ($s -is [System.IO.Stream]) {
|
|
93
|
+
$ms = New-Object System.IO.MemoryStream
|
|
94
|
+
$s.CopyTo($ms)
|
|
95
|
+
Write-Output ('STREAM:' + [System.Convert]::ToBase64String($ms.ToArray()))
|
|
96
|
+
exit 0
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Write-Output 'NONE'
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
const EXTENSION_MIME: Record<string, string> = {
|
|
104
|
+
".png": "image/png",
|
|
105
|
+
".jpg": "image/jpeg",
|
|
106
|
+
".jpeg": "image/jpeg",
|
|
107
|
+
".webp": "image/webp",
|
|
108
|
+
".gif": "image/gif",
|
|
109
|
+
".bmp": "image/bmp",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function sleep(ms: number): Promise<void> {
|
|
113
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function detectMime(bytes: Uint8Array): string {
|
|
117
|
+
if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
118
|
+
return "image/png";
|
|
119
|
+
}
|
|
120
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
121
|
+
return "image/jpeg";
|
|
122
|
+
}
|
|
123
|
+
if (bytes.length >= 4 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
|
|
124
|
+
return "image/gif";
|
|
125
|
+
}
|
|
126
|
+
if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
|
|
127
|
+
return "image/bmp";
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
bytes.length >= 12 &&
|
|
131
|
+
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
|
132
|
+
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
|
133
|
+
) {
|
|
134
|
+
return "image/webp";
|
|
135
|
+
}
|
|
136
|
+
return "image/png";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function decodeBase64(data: string, mimeType: string | null): ClipboardImage | null {
|
|
140
|
+
try {
|
|
141
|
+
const bytes = Uint8Array.from(Buffer.from(data, "base64"));
|
|
142
|
+
if (bytes.length === 0) return null;
|
|
143
|
+
return { bytes, mimeType: mimeType ?? detectMime(bytes) };
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parsePowerShellOutput(out: string | null): ClipboardImage | null {
|
|
150
|
+
if (!out || out === "NONE") return null;
|
|
151
|
+
|
|
152
|
+
if (out.startsWith("IMG:")) {
|
|
153
|
+
return decodeBase64(out.slice(4), "image/png");
|
|
154
|
+
}
|
|
155
|
+
if (out.startsWith("STREAM:")) {
|
|
156
|
+
return decodeBase64(out.slice(7), null);
|
|
157
|
+
}
|
|
158
|
+
if (out.startsWith("FILE:")) {
|
|
159
|
+
const rest = out.slice(5);
|
|
160
|
+
const sep = rest.indexOf(":");
|
|
161
|
+
if (sep < 0) return null;
|
|
162
|
+
const ext = rest.slice(0, sep).toLowerCase();
|
|
163
|
+
return decodeBase64(rest.slice(sep + 1), EXTENSION_MIME[ext] ?? null);
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Run the PowerShell snippet once. Resolves stdout on success, null on any failure/timeout. */
|
|
169
|
+
function runPowerShell(script: string): Promise<string | null> {
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
172
|
+
const child = spawn(
|
|
173
|
+
"powershell.exe",
|
|
174
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-STA", "-EncodedCommand", encoded],
|
|
175
|
+
{ windowsHide: true },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
let stdout = "";
|
|
179
|
+
let bytes = 0;
|
|
180
|
+
let settled = false;
|
|
181
|
+
let timer: NodeJS.Timeout | undefined;
|
|
182
|
+
const finish = (value: string | null): void => {
|
|
183
|
+
if (settled) return;
|
|
184
|
+
settled = true;
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
resolve(value);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
timer = setTimeout(() => {
|
|
190
|
+
child.kill("SIGKILL");
|
|
191
|
+
finish(null);
|
|
192
|
+
}, PS_TIMEOUT_MS);
|
|
193
|
+
|
|
194
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
195
|
+
bytes += chunk.length;
|
|
196
|
+
if (bytes > PS_MAX_STDOUT_BYTES) return;
|
|
197
|
+
stdout += chunk.toString("utf8");
|
|
198
|
+
});
|
|
199
|
+
child.on("error", () => finish(null));
|
|
200
|
+
child.on("close", (code) => finish(code === 0 ? stdout.trim() : null));
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read the Windows clipboard, retrying once because a screenshot app can still
|
|
206
|
+
* hold the clipboard open (CLIPBRD_E_CANT_OPEN) for a moment after Win+Shift+S.
|
|
207
|
+
*/
|
|
208
|
+
async function readWindowsClipboard(): Promise<ClipboardImage | null> {
|
|
209
|
+
for (let attempt = 0; attempt < PS_ATTEMPTS; attempt++) {
|
|
210
|
+
if (attempt > 0) await sleep(PS_RETRY_DELAY_MS);
|
|
211
|
+
const image = parsePowerShellOutput(await runPowerShell(PS_SCRIPT));
|
|
212
|
+
if (image) return image;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── macOS / Linux native fallback (pi-tui native clipboard helper) ──────────
|
|
218
|
+
|
|
219
|
+
async function readNativeClipboard(): Promise<ClipboardImage | null> {
|
|
220
|
+
try {
|
|
221
|
+
const native = getNativeClipboard();
|
|
222
|
+
const raw = await native?.getImage?.();
|
|
223
|
+
if (!raw || raw.length === 0) return null;
|
|
224
|
+
const bytes = raw instanceof Uint8Array ? raw : Uint8Array.from(raw);
|
|
225
|
+
return { bytes, mimeType: "image/png" };
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Linux clipboard tools (wl-paste / xclip) ────────────────────────────────
|
|
232
|
+
|
|
233
|
+
interface CommandResult {
|
|
234
|
+
ok: boolean;
|
|
235
|
+
stdout: Buffer;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function runCommand(command: string, args: string[], timeout: number): CommandResult {
|
|
239
|
+
const result = spawnSync(command, args, { timeout, maxBuffer: COMMAND_MAX_BUFFER_BYTES });
|
|
240
|
+
if (result.error) {
|
|
241
|
+
return { ok: false, stdout: Buffer.alloc(0) };
|
|
242
|
+
}
|
|
243
|
+
const stdout = Buffer.isBuffer(result.stdout)
|
|
244
|
+
? result.stdout
|
|
245
|
+
: Buffer.from(typeof result.stdout === "string" ? result.stdout : "");
|
|
246
|
+
return { ok: result.status === 0, stdout };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function normalizeMimeType(mimeType: string): string {
|
|
250
|
+
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
|
|
254
|
+
const normalized = mimeTypes
|
|
255
|
+
.map((mimeType) => mimeType.trim())
|
|
256
|
+
.filter((mimeType) => mimeType.length > 0)
|
|
257
|
+
.map((raw) => ({ raw, normalized: normalizeMimeType(raw) }));
|
|
258
|
+
for (const preferred of SUPPORTED_IMAGE_MIME_TYPES) {
|
|
259
|
+
const match = normalized.find((entry) => entry.normalized === preferred);
|
|
260
|
+
if (match) return match.raw;
|
|
261
|
+
}
|
|
262
|
+
return normalized.find((entry) => entry.normalized.startsWith("image/"))?.raw ?? null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readViaWlPaste(): ClipboardImage | null {
|
|
266
|
+
const list = runCommand("wl-paste", ["--list-types"], LINUX_LIST_TIMEOUT_MS);
|
|
267
|
+
if (!list.ok) return null;
|
|
268
|
+
|
|
269
|
+
const mimeTypes = list.stdout
|
|
270
|
+
.toString("utf8")
|
|
271
|
+
.split(/\r?\n/)
|
|
272
|
+
.map((mimeType) => mimeType.trim())
|
|
273
|
+
.filter(Boolean);
|
|
274
|
+
const selected = selectPreferredImageMimeType(mimeTypes);
|
|
275
|
+
if (!selected) return null;
|
|
276
|
+
|
|
277
|
+
const data = runCommand("wl-paste", ["--type", selected, "--no-newline"], LINUX_READ_TIMEOUT_MS);
|
|
278
|
+
if (!data.ok || data.stdout.length === 0) return null;
|
|
279
|
+
return { bytes: new Uint8Array(data.stdout), mimeType: normalizeMimeType(selected) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function readViaXclip(): ClipboardImage | null {
|
|
283
|
+
const targets = runCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], LINUX_LIST_TIMEOUT_MS);
|
|
284
|
+
const advertised = targets.ok
|
|
285
|
+
? targets.stdout
|
|
286
|
+
.toString("utf8")
|
|
287
|
+
.split(/\r?\n/)
|
|
288
|
+
.map((mimeType) => mimeType.trim())
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
: [];
|
|
291
|
+
|
|
292
|
+
const preferred = advertised.length > 0 ? selectPreferredImageMimeType(advertised) : null;
|
|
293
|
+
const mimeTypesToTry = preferred
|
|
294
|
+
? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES]
|
|
295
|
+
: [...SUPPORTED_IMAGE_MIME_TYPES];
|
|
296
|
+
|
|
297
|
+
for (const mimeType of mimeTypesToTry) {
|
|
298
|
+
const data = runCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"], LINUX_READ_TIMEOUT_MS);
|
|
299
|
+
if (data.ok && data.stdout.length > 0) {
|
|
300
|
+
return { bytes: new Uint8Array(data.stdout), mimeType: normalizeMimeType(mimeType) };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** wl-paste or xclip first (whichever matches the session), then the native helper. */
|
|
307
|
+
function readLinuxClipboard(): ClipboardImage | null {
|
|
308
|
+
const wayland =
|
|
309
|
+
Boolean(process.env.WAYLAND_DISPLAY) || process.env.XDG_SESSION_TYPE === "wayland";
|
|
310
|
+
const readers = wayland ? [readViaWlPaste, readViaXclip] : [readViaXclip, readViaWlPaste];
|
|
311
|
+
for (const reader of readers) {
|
|
312
|
+
const image = reader();
|
|
313
|
+
if (image) return image;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Windows and WSL both read the Windows clipboard; on WSL that means shelling
|
|
320
|
+
* out to powershell.exe (the Linux clipboard tools cannot see Windows
|
|
321
|
+
* screenshots). The base64 round-trip avoids the WSL file-bridge race that
|
|
322
|
+
* plagued pi's own fallback.
|
|
323
|
+
*/
|
|
324
|
+
function usesWindowsClipboard(): boolean {
|
|
325
|
+
return process.platform === "win32" || Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function readClipboardImage(): Promise<ClipboardImage | null> {
|
|
329
|
+
if (usesWindowsClipboard()) return readWindowsClipboard();
|
|
330
|
+
if (process.platform === "linux") {
|
|
331
|
+
const viaTools = readLinuxClipboard();
|
|
332
|
+
if (viaTools) return viaTools;
|
|
333
|
+
}
|
|
334
|
+
return readNativeClipboard();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Extension ───────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
// Alt+V and Ctrl+Alt+V on every platform. Ctrl+V is deliberately left to the
|
|
340
|
+
// terminal/built-in path paste so it can still forward non-image text.
|
|
341
|
+
const PASTE_SHORTCUTS = ["alt+v", "ctrl+alt+v"] as KeyId[];
|
|
342
|
+
|
|
343
|
+
export default function (pi: ExtensionAPI) {
|
|
344
|
+
let ctx: ExtensionContext | null = null;
|
|
345
|
+
let pending: PendingImage[] = [];
|
|
346
|
+
let nextIndex = 1;
|
|
347
|
+
let pasting = false;
|
|
348
|
+
// This bridge is independent of the draft attachment queue and UI context.
|
|
349
|
+
const clipboardReads = new Set<(result: ClipboardBridgeResult) => void>();
|
|
350
|
+
type ClipboardBridgeResult =
|
|
351
|
+
| { status: "image"; image: { mimeType: string; data: string } }
|
|
352
|
+
| { status: "empty" }
|
|
353
|
+
| { status: "error"; message: string };
|
|
354
|
+
const handleClipboardRequest = async (payload: unknown): Promise<void> => {
|
|
355
|
+
if (!payload || typeof payload !== "object") return;
|
|
356
|
+
const request = payload as { version?: unknown; requestId?: unknown };
|
|
357
|
+
if (request.version !== 1 || typeof request.requestId !== "string" || !request.requestId.trim()) return;
|
|
358
|
+
const requestId = request.requestId;
|
|
359
|
+
const finish = (result: ClipboardBridgeResult): void => {
|
|
360
|
+
if (!clipboardReads.delete(finish)) return;
|
|
361
|
+
pi.events.emit("pi-agent-ask:clipboard-result", { version: 1, requestId, ...result });
|
|
362
|
+
};
|
|
363
|
+
clipboardReads.add(finish);
|
|
364
|
+
// Presence detection requires this acknowledgement before the first await/read.
|
|
365
|
+
pi.events.emit("pi-agent-ask:clipboard-result", { version: 1, requestId, status: "reading" });
|
|
366
|
+
if (!clipboardReads.has(finish)) return;
|
|
367
|
+
try {
|
|
368
|
+
const image = await readClipboardImage();
|
|
369
|
+
if (!clipboardReads.has(finish)) return;
|
|
370
|
+
if (!image || image.bytes.length === 0) {
|
|
371
|
+
finish({ status: "empty" });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (image.bytes.length > MAX_IMAGE_BYTES) throw new Error("Clipboard image exceeds 20 MiB.");
|
|
375
|
+
const mimeType = normalizeMimeType(image.mimeType);
|
|
376
|
+
let result = { mimeType, data: Buffer.from(image.bytes).toString("base64") };
|
|
377
|
+
if (mimeType === "image/bmp") {
|
|
378
|
+
const converted = await convertToPng(result.data, mimeType);
|
|
379
|
+
if (!converted || converted.mimeType !== "image/png") throw new Error("Clipboard BMP conversion failed.");
|
|
380
|
+
const size = Buffer.from(converted.data, "base64").length;
|
|
381
|
+
if (!size || size > MAX_IMAGE_BYTES) throw new Error("Converted clipboard image is empty or exceeds 20 MiB.");
|
|
382
|
+
result = converted;
|
|
383
|
+
} else if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(mimeType)) {
|
|
384
|
+
throw new Error(`Unsupported clipboard image MIME type: ${mimeType}`);
|
|
385
|
+
}
|
|
386
|
+
finish({ status: "image", image: result });
|
|
387
|
+
} catch (error) {
|
|
388
|
+
finish({ status: "error", message: error instanceof Error ? error.message : String(error) });
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
// Register now, not in session_start: consumers can load before this extension.
|
|
392
|
+
let unsubscribeClipboard: (() => void) | undefined = pi.events.on("pi-agent-ask:clipboard-request", handleClipboardRequest);
|
|
393
|
+
const tempFiles = new Set<string>();
|
|
394
|
+
const removeTempFile = (path: string): void => {
|
|
395
|
+
try {
|
|
396
|
+
unlinkSync(path);
|
|
397
|
+
} catch {
|
|
398
|
+
// The file may already have been removed.
|
|
399
|
+
}
|
|
400
|
+
tempFiles.delete(path);
|
|
401
|
+
};
|
|
402
|
+
const cleanTempFiles = (): void => {
|
|
403
|
+
for (const path of tempFiles) removeTempFile(path);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const attachFromClipboard = async (): Promise<void> => {
|
|
407
|
+
if (pasting || !ctx || !ctx.hasUI) return;
|
|
408
|
+
pasting = true;
|
|
409
|
+
try {
|
|
410
|
+
const image = await readClipboardImage();
|
|
411
|
+
|
|
412
|
+
if (!image) {
|
|
413
|
+
ctx.ui.notify(
|
|
414
|
+
"No image on the clipboard. Copy a screenshot (Win+Shift+S) or an image file, then Alt+V.",
|
|
415
|
+
"warning",
|
|
416
|
+
);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (image.bytes.length === 0) {
|
|
420
|
+
ctx.ui.notify("Clipboard image is empty (0 bytes).", "warning");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (image.bytes.length > MAX_IMAGE_BYTES) {
|
|
424
|
+
ctx.ui.notify(
|
|
425
|
+
`Image too large (${(image.bytes.length / 1024 / 1024).toFixed(1)} MB > 20 MB).`,
|
|
426
|
+
"warning",
|
|
427
|
+
);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Write to temp file for vision-tools CLI access
|
|
432
|
+
try {
|
|
433
|
+
const ext = image.mimeType.split("/")[1] || "png";
|
|
434
|
+
const filename = `clipboard-${Date.now()}-${randomUUID()}.${ext}`;
|
|
435
|
+
const tempDir = join(tmpdir(), "pi-clipboard-images");
|
|
436
|
+
mkdirSync(tempDir, { recursive: true });
|
|
437
|
+
const tempPath = join(tempDir, filename);
|
|
438
|
+
|
|
439
|
+
const buffer = Buffer.from(image.bytes);
|
|
440
|
+
const base64 = buffer.toString("base64");
|
|
441
|
+
writeFileSync(tempPath, buffer);
|
|
442
|
+
tempFiles.add(tempPath);
|
|
443
|
+
|
|
444
|
+
const index = nextIndex++;
|
|
445
|
+
const marker = `[Image #${index}] `;
|
|
446
|
+
pending.push({
|
|
447
|
+
id: randomUUID(),
|
|
448
|
+
base64,
|
|
449
|
+
path: tempPath,
|
|
450
|
+
mimeType: image.mimeType,
|
|
451
|
+
marker,
|
|
452
|
+
});
|
|
453
|
+
ctx.ui.pasteToEditor(marker);
|
|
454
|
+
ctx.ui.notify(`Image #${index} attached — send the message to include it.`, "info");
|
|
455
|
+
} catch (writeError) {
|
|
456
|
+
const msg = writeError instanceof Error ? writeError.message : String(writeError);
|
|
457
|
+
ctx.ui.notify(`Failed to write image file: ${msg}`, "error");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
} catch (error) {
|
|
461
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
462
|
+
ctx.ui.notify(`Image paste failed: ${msg}`, "warning");
|
|
463
|
+
} finally {
|
|
464
|
+
pasting = false;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
for (const shortcut of PASTE_SHORTCUTS) {
|
|
469
|
+
pi.registerShortcut(shortcut, {
|
|
470
|
+
description: "Attach clipboard image to draft",
|
|
471
|
+
handler: attachFromClipboard,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Attach queued images on submit. Markers are the source of truth: delete
|
|
476
|
+
// `[Image #N]` and that image is not sent.
|
|
477
|
+
pi.on("input", (event) => {
|
|
478
|
+
if (event.source === "extension" || pending.length === 0) {
|
|
479
|
+
return { action: "continue" as const };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const images: Array<{ type: "image"; data: string; mimeType: string }> = [];
|
|
483
|
+
const submittedPaths: string[] = [];
|
|
484
|
+
for (const item of pending) {
|
|
485
|
+
if (event.text.includes(item.marker.trim())) {
|
|
486
|
+
images.push({ type: "image" as const, data: item.base64, mimeType: item.mimeType });
|
|
487
|
+
submittedPaths.push(item.path);
|
|
488
|
+
} else {
|
|
489
|
+
removeTempFile(item.path);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
pending = [];
|
|
494
|
+
nextIndex = 1;
|
|
495
|
+
|
|
496
|
+
if (images.length === 0) {
|
|
497
|
+
return { action: "continue" as const };
|
|
498
|
+
}
|
|
499
|
+
const pathsText = submittedPaths.map((path, idx) => `Image #${idx + 1}: ${path}`).join("\n");
|
|
500
|
+
const text = pathsText ? `${event.text}\n${pathsText}` : event.text;
|
|
501
|
+
return { action: "transform" as const, text, images };
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
pi.on("session_start", (_event, sessionCtx) => {
|
|
505
|
+
if (!unsubscribeClipboard) {
|
|
506
|
+
unsubscribeClipboard = pi.events.on("pi-agent-ask:clipboard-request", handleClipboardRequest);
|
|
507
|
+
}
|
|
508
|
+
cleanTempFiles();
|
|
509
|
+
ctx = sessionCtx;
|
|
510
|
+
pending = [];
|
|
511
|
+
nextIndex = 1;
|
|
512
|
+
pasting = false;
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
pi.on("session_shutdown", () => {
|
|
516
|
+
unsubscribeClipboard?.();
|
|
517
|
+
unsubscribeClipboard = undefined;
|
|
518
|
+
for (const finish of clipboardReads) {
|
|
519
|
+
finish({ status: "error", message: "Clipboard provider session shut down." });
|
|
520
|
+
}
|
|
521
|
+
cleanTempFiles();
|
|
522
|
+
ctx = null;
|
|
523
|
+
pending = [];
|
|
524
|
+
nextIndex = 1;
|
|
525
|
+
pasting = false;
|
|
526
|
+
});
|
|
527
|
+
}
|