@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
File without changes