@quandev104/pi-style 0.2.2 → 0.2.3
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 +674 -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 +288 -0
- package/extension-src/pi-style/features/messages/render-config.ts +36 -0
- package/extension-src/pi-style/pi/index.ts +53 -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,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
|