@quandev104/pi-style 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +8 -3
- package/dist/extensions/pi-style.js +715 -49
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +8 -0
- package/extension-src/pi-style/app/runtime.ts +4 -0
- package/extension-src/pi-style/domain/config-normalization.ts +11 -1
- package/extension-src/pi-style/domain/config-types.ts +12 -0
- package/extension-src/pi-style/domain/status-renderer.ts +1 -1
- package/extension-src/pi-style/domain/status.ts +13 -7
- package/extension-src/pi-style/features/editor/index.ts +127 -0
- package/extension-src/pi-style/features/messages/image-input.ts +205 -0
- package/extension-src/pi-style/features/messages/image-preview.ts +335 -0
- package/extension-src/pi-style/features/messages/render-config.ts +36 -0
- package/extension-src/pi-style/pi/index.ts +68 -2
- package/extension-src/pi-style/pi/session-coordinator.ts +13 -0
- package/extension-src/pi-style/shared/clipboard-path.ts +98 -0
- package/extension-src/pi-style/shared/clipboard-presence.ts +112 -0
- package/extension-src/pi-style/shared/pending-images.ts +199 -0
- package/package.json +1 -1
- package/extension-src/pi-style/features/.gitkeep +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Sync clipboard-image presence probe (ADR 0009 instant-marker surface).
|
|
2
|
+
//
|
|
3
|
+
// Pi's built-in paste reads the clipboard asynchronously (native module,
|
|
4
|
+
// ~90ms for a screenshot) before it can tell whether the clipboard even holds
|
|
5
|
+
// an image. The native module also exposes a synchronous hasImage() poll —
|
|
6
|
+
// resolving it through Pi's own install lets the editor insert the
|
|
7
|
+
// `[Image #N] ` marker at keystroke time (zero-latency feedback) instead of
|
|
8
|
+
// after the read, while text pastes stay untouched (no marker flash).
|
|
9
|
+
//
|
|
10
|
+
// Resolution mirrors Pi's clipboard-native.js: require "@mariozechner/clipboard"
|
|
11
|
+
// relative to the pi-coding-agent package, where the module is installed.
|
|
12
|
+
// Unresolvable (bundled/aliased hosts, Termux) → null → caller falls back to
|
|
13
|
+
// the artifact-time marker path.
|
|
14
|
+
|
|
15
|
+
import { realpathSync } from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
|
|
19
|
+
interface ClipboardPresenceModule {
|
|
20
|
+
hasImage(): boolean;
|
|
21
|
+
getImageBinary?(): Promise<Array<number> | Uint8Array>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let cachedModule: ClipboardPresenceModule | null | undefined;
|
|
25
|
+
|
|
26
|
+
/** Resolution roots, ordered: the running pi process's own module graph first
|
|
27
|
+
* (process.argv[1] is pi's cli — its install carries @mariozechner/clipboard
|
|
28
|
+
* as an optionalDependency, possibly nested and NOT visible from the
|
|
29
|
+
* extension project's node_modules), then the extension's own graph.
|
|
30
|
+
* argv[1] may be a symlink (bin/pi) — realpath it first so the require root
|
|
31
|
+
* lands inside the real install. */
|
|
32
|
+
function clipboardResolutionRoots(): string[] {
|
|
33
|
+
const roots: string[] = [];
|
|
34
|
+
const argvMain = process.argv[1];
|
|
35
|
+
if (typeof argvMain === "string" && argvMain.startsWith("/")) {
|
|
36
|
+
try {
|
|
37
|
+
roots.push(dirname(realpathSync(argvMain)));
|
|
38
|
+
} catch {
|
|
39
|
+
roots.push(dirname(argvMain));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
roots.push(import.meta.url);
|
|
43
|
+
return roots;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadClipboardModule(): ClipboardPresenceModule | null {
|
|
47
|
+
if (cachedModule !== undefined) return cachedModule;
|
|
48
|
+
if (process.env.TERMUX_VERSION) {
|
|
49
|
+
cachedModule = null;
|
|
50
|
+
return cachedModule;
|
|
51
|
+
}
|
|
52
|
+
for (const root of clipboardResolutionRoots()) {
|
|
53
|
+
try {
|
|
54
|
+
const require = createRequire(root);
|
|
55
|
+
const resolved = require("@mariozechner/clipboard") as ClipboardPresenceModule;
|
|
56
|
+
if (resolved && typeof resolved.hasImage === "function") {
|
|
57
|
+
cachedModule = resolved;
|
|
58
|
+
return cachedModule;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Try the next resolution root.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
cachedModule = null;
|
|
65
|
+
return cachedModule;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when the system clipboard currently holds an image; null when the
|
|
70
|
+
* native clipboard module is unavailable (caller must not use the result to
|
|
71
|
+
* gate instant feedback — fall back to the async path instead).
|
|
72
|
+
*/
|
|
73
|
+
export function clipboardHasImageSync(): boolean | null {
|
|
74
|
+
const clipboard = loadClipboardModule();
|
|
75
|
+
if (!clipboard || typeof clipboard.hasImage !== "function") return null;
|
|
76
|
+
try {
|
|
77
|
+
return clipboard.hasImage() === true;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read the clipboard image bytes directly through the native module (PNG on
|
|
85
|
+
* platforms where the module reports images). Null when unavailable or the
|
|
86
|
+
* clipboard holds no image — callers fall back to artifact-time handling.
|
|
87
|
+
* `skipPresenceProbe`: for callers that already confirmed presence via
|
|
88
|
+
* hasImage() (the owned-paste path probes at keystroke time) — skips the
|
|
89
|
+
* redundant second native probe. Default behavior is unchanged.
|
|
90
|
+
*/
|
|
91
|
+
export async function readClipboardImageBinary(options: { skipPresenceProbe?: boolean } = {}): Promise<{
|
|
92
|
+
bytes: Uint8Array;
|
|
93
|
+
} | null> {
|
|
94
|
+
const clipboard = loadClipboardModule();
|
|
95
|
+
if (!clipboard || typeof clipboard.hasImage !== "function" || typeof clipboard.getImageBinary !== "function") {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
if (!options.skipPresenceProbe && !clipboard.hasImage()) return null;
|
|
100
|
+
const imageData = await clipboard.getImageBinary();
|
|
101
|
+
if (!imageData || imageData.length === 0) return null;
|
|
102
|
+
const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
|
|
103
|
+
return bytes.length > 0 ? { bytes } : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Test seam: forget the cached module resolution. */
|
|
110
|
+
export function resetClipboardPresenceCache(): void {
|
|
111
|
+
cachedModule = undefined;
|
|
112
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Pending clipboard-image registry (ADR 0009): markers ↔ image bytes.
|
|
2
|
+
//
|
|
3
|
+
// Shared state between the editor feature (allocates/discards markers at
|
|
4
|
+
// keystroke time — instant feedback, image bytes arrive later) and the
|
|
5
|
+
// messages feature (fills entries from artifacts, resolves markers at
|
|
6
|
+
// submit). Lives in shared/ so both features can use it without
|
|
7
|
+
// cross-feature imports.
|
|
8
|
+
//
|
|
9
|
+
// Lifecycle: entries are created by allocatePendingImage() when a paste
|
|
10
|
+
// keystroke inserts an `[Image #N] ` marker; filled asynchronously by
|
|
11
|
+
// fillPendingImageFromPath() once the bytes exist (editor artifact read or
|
|
12
|
+
// fallback); consumed one-shot by resolvePendingImagesForSubmit() (markers
|
|
13
|
+
// present in the submitted text attach, removed markers discard); discarded
|
|
14
|
+
// by discardPendingImage() when the user backspaces a whole marker or a
|
|
15
|
+
// rollback removes it. Session boundaries reset everything.
|
|
16
|
+
|
|
17
|
+
import { readFile, stat } from "node:fs/promises";
|
|
18
|
+
import { clipboardImageMarker } from "./clipboard-path.js";
|
|
19
|
+
|
|
20
|
+
/** Mirrors image-paste's guard; larger files never register. */
|
|
21
|
+
const MAX_ATTACHABLE_BYTES = 20 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
const MIME_BY_EXTENSION: Readonly<Record<string, string>> = Object.freeze({
|
|
24
|
+
png: "image/png",
|
|
25
|
+
jpg: "image/jpeg",
|
|
26
|
+
jpeg: "image/jpeg",
|
|
27
|
+
webp: "image/webp",
|
|
28
|
+
gif: "image/gif",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
interface PendingEntry {
|
|
32
|
+
/** Resolves once fill completes (success or failure); submit awaits it. */
|
|
33
|
+
readonly fill: Promise<void>;
|
|
34
|
+
data?: string;
|
|
35
|
+
mimeType?: string;
|
|
36
|
+
settled: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pendingImages = new Map<number, PendingEntry>();
|
|
40
|
+
let nextPendingIndex = 1;
|
|
41
|
+
|
|
42
|
+
/** Reset the registry and marker counter (session start/shutdown). */
|
|
43
|
+
export function resetPendingImageRegistry(): void {
|
|
44
|
+
pendingImages.clear();
|
|
45
|
+
nextPendingIndex = 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Allocate the next marker index (marker text: `[Image #N] `). */
|
|
49
|
+
export function allocatePendingImage(): { index: number; marker: string } {
|
|
50
|
+
const index = nextPendingIndex++;
|
|
51
|
+
let resolveFill: () => void = () => {};
|
|
52
|
+
const entry: PendingEntry = {
|
|
53
|
+
fill: new Promise<void>((resolve) => {
|
|
54
|
+
resolveFill = resolve;
|
|
55
|
+
}),
|
|
56
|
+
settled: false,
|
|
57
|
+
};
|
|
58
|
+
pendingImages.set(index, entry);
|
|
59
|
+
(entry as { resolveFill?: () => void }).resolveFill = resolveFill;
|
|
60
|
+
return { index, marker: `${clipboardImageMarker(index)} ` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Fill an allocated entry from a clipboard artifact file (stat + read + base64).
|
|
64
|
+
* Guards: missing/empty/oversized/unknown-extension → entry stays unfilled
|
|
65
|
+
* (marker resolves to plain text at submit — nothing is lost). */
|
|
66
|
+
export async function fillPendingImageFromPath(
|
|
67
|
+
index: number,
|
|
68
|
+
path: string,
|
|
69
|
+
deps: {
|
|
70
|
+
readFile?: (path: string) => Promise<Uint8Array>;
|
|
71
|
+
statSize?: (path: string) => Promise<number | undefined>;
|
|
72
|
+
} = {},
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const entry = pendingImages.get(index);
|
|
75
|
+
if (!entry || entry.settled) return;
|
|
76
|
+
const extension = path.split(".").pop() ?? "";
|
|
77
|
+
const mimeType = MIME_BY_EXTENSION[extension];
|
|
78
|
+
if (!mimeType) {
|
|
79
|
+
settle(entry);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const readFileImpl = deps.readFile ?? ((p: string) => readFile(p));
|
|
83
|
+
const statSizeImpl =
|
|
84
|
+
deps.statSize ??
|
|
85
|
+
(async (p: string) => {
|
|
86
|
+
try {
|
|
87
|
+
return (await stat(p)).size;
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
const size = await statSizeImpl(path);
|
|
94
|
+
if (size === undefined || size <= 0 || size > MAX_ATTACHABLE_BYTES) {
|
|
95
|
+
settle(entry);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const bytes = await readFileImpl(path);
|
|
99
|
+
if (bytes.length === 0 || bytes.length > MAX_ATTACHABLE_BYTES) {
|
|
100
|
+
settle(entry);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Zero-copy view (offset+length explicit so Uint8Array views over a larger
|
|
104
|
+
// backing buffer encode exactly their own range — Buffer.from(bytes)
|
|
105
|
+
// would copy the whole clipboard screenshot again).
|
|
106
|
+
entry.data = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
107
|
+
entry.mimeType = mimeType;
|
|
108
|
+
} catch {
|
|
109
|
+
// Unreadable artifact: marker stays plain text.
|
|
110
|
+
}
|
|
111
|
+
settle(entry);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fill an allocated entry directly from in-memory bytes. */
|
|
115
|
+
export function fillPendingImageBytes(index: number, data: string, mimeType: string): void {
|
|
116
|
+
const entry = pendingImages.get(index);
|
|
117
|
+
if (!entry || entry.settled) return;
|
|
118
|
+
if (data.length > 0) {
|
|
119
|
+
entry.data = data;
|
|
120
|
+
entry.mimeType = mimeType;
|
|
121
|
+
}
|
|
122
|
+
settle(entry);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Discard a pending entry (whole-marker backspace, rollback, user removal). */
|
|
126
|
+
export function discardPendingImage(index: number): void {
|
|
127
|
+
const entry = pendingImages.get(index);
|
|
128
|
+
if (!entry) return;
|
|
129
|
+
settle(entry);
|
|
130
|
+
pendingImages.delete(index);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Marker text for an allocated index (editor rollback surgery). */
|
|
134
|
+
export function pendingImageMarker(index: number): string | undefined {
|
|
135
|
+
return pendingImages.has(index) ? `${clipboardImageMarker(index)} ` : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** True when `index` has an allocated (not yet consumed/discarded) entry. */
|
|
139
|
+
export function isPendingMarkerIndex(index: number): boolean {
|
|
140
|
+
return pendingImages.has(index);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Discard by marker index (atomic backspace / rollback). */
|
|
144
|
+
export function discardPendingMarkerIndex(index: number): void {
|
|
145
|
+
discardPendingImage(index);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True when the entry for `index` was successfully filled with bytes. */
|
|
149
|
+
export function isPendingImageFilled(index: number): boolean {
|
|
150
|
+
const entry = pendingImages.get(index);
|
|
151
|
+
return entry !== undefined && entry.data !== undefined && entry.mimeType !== undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function settle(entry: PendingEntry): void {
|
|
155
|
+
entry.settled = true;
|
|
156
|
+
(entry as { resolveFill?: () => void }).resolveFill?.();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface PendingImageAttachment {
|
|
160
|
+
readonly type: "image";
|
|
161
|
+
readonly data: string;
|
|
162
|
+
readonly mimeType: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve `[Image #N]` markers in a submitted text against the registry:
|
|
167
|
+
* awaits pending fills (a fast submit racing a slow artifact read), attaches
|
|
168
|
+
* filled images in ascending index order, keeps the text verbatim, and
|
|
169
|
+
* consumes the registry one-shot per submit — markers removed from the text
|
|
170
|
+
* discard their images (image-paste semantics: the pending queue never
|
|
171
|
+
* outlives the submit after the paste).
|
|
172
|
+
*/
|
|
173
|
+
export async function resolvePendingImagesForSubmit(
|
|
174
|
+
text: string,
|
|
175
|
+
): Promise<{ images: PendingImageAttachment[] } | undefined> {
|
|
176
|
+
if (pendingImages.size === 0) return undefined;
|
|
177
|
+
const markerRe = /\[Image #([0-9]+)\]/g;
|
|
178
|
+
const found: number[] = [];
|
|
179
|
+
for (const match of text.matchAll(markerRe)) {
|
|
180
|
+
const index = Number(match[1]);
|
|
181
|
+
if (Number.isInteger(index) && pendingImages.has(index)) found.push(index);
|
|
182
|
+
}
|
|
183
|
+
const matched = new Set(found);
|
|
184
|
+
// Wait for in-flight fills so a submit right after a paste still attaches.
|
|
185
|
+
await Promise.all(
|
|
186
|
+
[...pendingImages.entries()].filter(([index]) => matched.has(index)).map(([, entry]) => entry.fill),
|
|
187
|
+
);
|
|
188
|
+
const images: PendingImageAttachment[] = [];
|
|
189
|
+
for (const index of [...matched].sort((a, b) => a - b)) {
|
|
190
|
+
const entry = pendingImages.get(index);
|
|
191
|
+
if (entry?.data && entry.mimeType) {
|
|
192
|
+
images.push({ type: "image", data: entry.data, mimeType: entry.mimeType });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// One-shot consume: every pending entry is spent by this submit — matched
|
|
196
|
+
// (attached or discarded) and unmatched (their markers were removed).
|
|
197
|
+
for (const index of [...pendingImages.keys()]) discardPendingImage(index);
|
|
198
|
+
return images.length > 0 ? { images } : undefined;
|
|
199
|
+
}
|
package/package.json
CHANGED
|
File without changes
|