@quandev104/pi-style 0.2.1 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +8 -3
  3. package/dist/extensions/pi-style.js +1328 -333
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +8 -0
  6. package/extension-src/pi-style/app/runtime.ts +33 -4
  7. package/extension-src/pi-style/domain/config-normalization.ts +11 -1
  8. package/extension-src/pi-style/domain/config-types.ts +12 -0
  9. package/extension-src/pi-style/domain/status-renderer.ts +41 -9
  10. package/extension-src/pi-style/domain/status.ts +28 -12
  11. package/extension-src/pi-style/domain/theme.ts +32 -1
  12. package/extension-src/pi-style/features/editor/index.ts +144 -5
  13. package/extension-src/pi-style/features/messages/image-input.ts +205 -0
  14. package/extension-src/pi-style/features/messages/image-preview.ts +288 -0
  15. package/extension-src/pi-style/features/messages/index.ts +302 -61
  16. package/extension-src/pi-style/features/messages/render-config.ts +36 -0
  17. package/extension-src/pi-style/features/status-line/index.ts +41 -11
  18. package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
  19. package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
  20. package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
  21. package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
  22. package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
  23. package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
  24. package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
  25. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
  26. package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
  27. package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
  28. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
  29. package/extension-src/pi-style/pi/index.ts +55 -2
  30. package/extension-src/pi-style/pi/session-coordinator.ts +13 -0
  31. package/extension-src/pi-style/shared/ansi.ts +17 -5
  32. package/extension-src/pi-style/shared/box.ts +70 -4
  33. package/extension-src/pi-style/shared/clipboard-path.ts +98 -0
  34. package/extension-src/pi-style/shared/clipboard-presence.ts +112 -0
  35. package/extension-src/pi-style/shared/pending-images.ts +199 -0
  36. package/extension-src/pi-style/shared/split-diff.ts +8 -5
  37. package/package.json +1 -1
  38. package/extension-src/pi-style/features/.gitkeep +0 -0
@@ -1,5 +1,12 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { StatusSnapshot } from "../domain/status.js";
3
+ import { resolvePendingImageMarkers, transformClipboardImages } from "../features/messages/image-input.js";
4
+ import {
5
+ flushImagePreviewEntry,
6
+ type ImagePreviewEntryData,
7
+ registerImagePreviewSurface,
8
+ stageImagePreviewData,
9
+ } from "../features/messages/image-preview.js";
3
10
  import { closeActiveBatch } from "../features/tools/boxed/batch.js";
4
11
  import {
5
12
  beginAgentRun,
@@ -7,6 +14,7 @@ import {
7
14
  invalidateTurnMembers,
8
15
  rebuildTurnRegistryFromEntries,
9
16
  registerTurnFromMessage,
17
+ releaseTurnInvalidators,
10
18
  } from "../features/tools/boxed/turn-summary.js";
11
19
  import { requestToolPresentationRender } from "../features/tools/index.js";
12
20
  import { registerPiStyleCommand } from "./commands.js";
@@ -63,6 +71,27 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
63
71
  pi.registerFlag("pi-style-ascii", { type: "boolean", description: "Use ASCII pi-style markers" });
64
72
  const coordinator = createPiStyleSessionCoordinator(pi, compatibilityTestHooks);
65
73
  registerPiStyleCommand(pi, coordinator.app);
74
+ // User-prompt image previews (ADR 0008): the entry renderer must be
75
+ // registered before the first entry of this type can exist — the host drops
76
+ // entries whose customType has no renderer — and once at extension load is
77
+ // enough (persisted entries from previous sessions render on resume).
78
+ registerImagePreviewSurface(pi);
79
+ // User-prompt image previews (ADR 0008): stage at `before_agent_start` (the
80
+ // only event carrying the prompt's `images`), flush as a display-only entry
81
+ // at the first `message_start(assistant)`. Appending at before_agent_start
82
+ // would land the entry ABOVE the user message — the user message enters the
83
+ // feed only when UI listeners process message_start(user) and persists at
84
+ // message_end(user), both after extension handlers. At the first assistant
85
+ // message the user message is already rendered and persisted, so the host
86
+ // inserts the entry below it (spliced before the streaming component) and
87
+ // the session file records user → preview → assistant for identical resume.
88
+ // Edge: a steered prompt arriving before the first flush overwrites the
89
+ // staged slot (last write wins) — steer-with-image is a rare corner.
90
+ let stagedImagePreview: ImagePreviewEntryData | undefined;
91
+ pi.on("before_agent_start", (event) => {
92
+ const staged = stageImagePreviewData(event.images ?? []);
93
+ if (staged) stagedImagePreview = staged;
94
+ });
66
95
  pi.on("session_start", async (event, ctx) => {
67
96
  resetUsageFromSessionCache(ctx.sessionManager);
68
97
  // Pi only activates read/bash/edit/write by default; grep/find/ls are
@@ -80,7 +109,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
80
109
  // (user request → agent_end), not pi's per-message turn_end.
81
110
  beginAgentRun();
82
111
  });
83
- pi.on("input", (event, _ctx) => {
112
+ pi.on("input", async (event, _ctx) => {
84
113
  coordinator.app.runtime.current?.dismissStartup();
85
114
  // Bare `!`/`!!` submit guard: Pi treats `!`-prefixed input as a direct bash
86
115
  // command but falls through to normal message submission when the bang has
@@ -96,6 +125,23 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
96
125
  if (trimmed.slice(bangLength).trim() === "") return { action: "handled" };
97
126
  }
98
127
  }
128
+ // Clipboard image input (ADR 0009): `[Image #N]` markers resolve for EVERY
129
+ // submit source — a submit is a submit (image-paste semantics); consuming
130
+ // the registry one-shot on rpc/extension submits too keeps a pasted marker
131
+ // from dangling into a later, unrelated prompt. No-op when the registry is
132
+ // empty. Raw clipboard path tokens, in contrast, only ever come from the
133
+ // interactive editor's built-in paste — that upgrade stays interactive-only.
134
+ // Combined images flow on to before_agent_start, where the ADR 0008 preview
135
+ // entry picks them up.
136
+ const markerResult = await resolvePendingImageMarkers(event.text);
137
+ const pathResult = event.source === "interactive" ? await transformClipboardImages(event.text) : undefined;
138
+ if (markerResult || pathResult) {
139
+ return {
140
+ action: "transform" as const,
141
+ text: pathResult?.text ?? event.text,
142
+ images: [...(markerResult?.images ?? []), ...(pathResult?.images ?? [])],
143
+ };
144
+ }
99
145
  return undefined;
100
146
  });
101
147
  pi.on("tool_execution_start", () => coordinator.app.runtime.current?.dismissStartup());
@@ -113,10 +159,16 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
113
159
  pi.on("session_info_changed", (event) =>
114
160
  coordinator.app.update({ sessionName: event.name }, "coalesced", { refreshExtensionStatuses: true }),
115
161
  );
116
- pi.on("message_start", () => {
162
+ pi.on("message_start", (event) => {
117
163
  // A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
118
164
  // new message start a fresh batch instead of joining the previous one.
119
165
  closeActiveBatch();
166
+ // Flush the staged image preview below the just-rendered user message
167
+ // (ADR 0008 ordering — see the before_agent_start comment above).
168
+ if (stagedImagePreview && event.message?.role === "assistant") {
169
+ flushImagePreviewEntry(pi, stagedImagePreview);
170
+ stagedImagePreview = undefined;
171
+ }
120
172
  // grep/bash tree panels are NOT cleared here: historical panels must keep
121
173
  // their state so Pi re-renders of previous messages (scroll/resume) stay
122
174
  // intact. Only session boundaries reset them (session-coordinator).
@@ -142,6 +194,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
142
194
  const run = finishAgentRun();
143
195
  if (run) {
144
196
  invalidateTurnMembers(run);
197
+ releaseTurnInvalidators(run);
145
198
  requestToolPresentationRender();
146
199
  }
147
200
  });
@@ -3,6 +3,8 @@ import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
3
3
  import type { ConfigFilePort } from "../app/config-storage.js";
4
4
  import { createPiStyleApp, type PiStyleApp } from "../app/index.js";
5
5
  import { resolveTheme } from "../domain/theme.js";
6
+ import { resetPendingImageRegistry } from "../features/messages/image-input.js";
7
+ import { setMessagesRenderConfig } from "../features/messages/render-config.js";
6
8
  import { setSpecialBlockTheme } from "../features/messages/special-blocks.js";
7
9
  import { setBashExecutionTheme } from "../features/tools/bash-execution.js";
8
10
  import { resetBashTreeRegistry } from "../features/tools/boxed/bash.js";
@@ -95,6 +97,14 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
95
97
  */
96
98
  const applyMessagesConfig = (config: import("../domain/config-types.js").NormalizedPiStyleConfig) => {
97
99
  sessionUi?.setHiddenThinkingLabel?.(config.messages.hideThinkingLabel ? "" : undefined);
100
+ // User-prompt image previews (ADR 0008) + clipboard image input (ADR
101
+ // 0009): the leaves gate their respective sides (preview: stage+render;
102
+ // clipboard: input transform) and size the preview images.
103
+ setMessagesRenderConfig({
104
+ showImagePreviews: config.messages.showImagePreviews,
105
+ clipboardImages: config.messages.clipboardImages,
106
+ previewMaxWidth: config.messages.previewMaxWidth,
107
+ });
98
108
  };
99
109
  /**
100
110
  * Auto-apply the configured pi-style theme (default "titanium") once per TUI
@@ -176,6 +186,8 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
176
186
  resetBatchRegistry();
177
187
  resetGrepRegistry();
178
188
  resetBashTreeRegistry();
189
+ // Clipboard image pending markers (ADR 0009) never cross sessions.
190
+ resetPendingImageRegistry();
179
191
  // Turn summaries (ADR 0007): rebuild the registry from session content so
180
192
  // restored/forked history renders collapsed before the first render pass
181
193
  // (deterministic; no in-process turn_end events needed).
@@ -253,6 +265,7 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
253
265
  resetBatchRegistry();
254
266
  resetGrepRegistry();
255
267
  resetBashTreeRegistry();
268
+ resetPendingImageRegistry();
256
269
  resetTurnRegistry();
257
270
  stopAllElapsedTickers();
258
271
  app.sessionShutdown();
@@ -1,3 +1,5 @@
1
+ import { visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
2
+
1
3
  function isFinal(byte: string): boolean {
2
4
  return byte >= "@" && byte <= "~";
3
5
  }
@@ -91,8 +93,12 @@ export function stripAnsi(value: string): string {
91
93
  }
92
94
  return output;
93
95
  }
96
+ /**
97
+ * Terminal-correct visible width: delegates to pi-tui (ANSI-stripping,
98
+ * ASCII fast path, per-string cache, wide chars = 2 columns, tabs = 3).
99
+ */
94
100
  export function visibleWidth(value: string): number {
95
- return [...stripAnsi(value)].length;
101
+ return tuiVisibleWidth(value);
96
102
  }
97
103
  export function resetAnsi(value: string): string {
98
104
  return `${value}\x1b[0m`;
@@ -105,9 +111,10 @@ export function fitAnsiWidth(value: string, width: number, ellipsis = "…"): st
105
111
  export function truncateAnsi(value: string, width: number, ellipsis = "…"): string {
106
112
  if (width <= 0) return "";
107
113
  if (visibleWidth(value) <= width) return resetAnsi(value);
114
+ const ellipsisWidth = visibleWidth(ellipsis);
108
115
  let output = "";
109
116
  let visible = 0;
110
- for (let i = 0; i < value.length && visible < width - visibleWidth(ellipsis); i++) {
117
+ for (let i = 0; i < value.length && visible < width - ellipsisWidth; i++) {
111
118
  if (value.charCodeAt(i) === 27) {
112
119
  const start = i;
113
120
  i++;
@@ -125,12 +132,17 @@ export function wrapAnsi(value: string, width: number): string[] {
125
132
  if (width <= 0) return [""];
126
133
  const lines: string[] = [];
127
134
  let line = "";
135
+ let lineWidth = 0;
128
136
  for (const word of value.split(/\s+/)) {
129
- const next = line ? `${line} ${word}` : word;
130
- if (visibleWidth(next) <= width) line = next;
131
- else {
137
+ const wordWidth = visibleWidth(word);
138
+ const nextWidth = line ? lineWidth + 1 + wordWidth : wordWidth;
139
+ if (nextWidth <= width) {
140
+ line = line ? `${line} ${word}` : word;
141
+ lineWidth = nextWidth;
142
+ } else {
132
143
  if (line) lines.push(resetAnsi(line));
133
144
  line = truncateAnsi(word, width);
145
+ lineWidth = visibleWidth(line);
134
146
  }
135
147
  }
136
148
  if (line || lines.length === 0) lines.push(resetAnsi(line));
@@ -149,12 +149,63 @@ export function countLines(text: string): number {
149
149
  return normalized.split("\n").length;
150
150
  }
151
151
 
152
+ // Word-character membership table powering countWords. ASCII and the UTF-16
153
+ // surrogate range are initialized eagerly; every other BMP code point is
154
+ // resolved through the Unicode letter/digit class on first sight and memoized,
155
+ // so repeat scans are pure table lookups. The per-code-point regex dispatch
156
+ // this replaces measured ~1.9ms per 90KB output on every boxed footer render;
157
+ // note that String.match with a \p{L}\p{N} class is no faster on V8 — the
158
+ // memoized scan is the only variant that hit the <0.2ms budget.
159
+ const WORD_CP_UNKNOWN = 255;
160
+ const WORD_CP_SURROGATE = 254;
161
+ const WORD_CP_CLASS = new Uint8Array(0x10000).fill(WORD_CP_UNKNOWN);
162
+ for (let code = 0x30; code <= 0x39; code++) WORD_CP_CLASS[code] = 1; // 0-9
163
+ for (let code = 0x41; code <= 0x5a; code++) WORD_CP_CLASS[code] = 1; // A-Z
164
+ for (let code = 0x61; code <= 0x7a; code++) WORD_CP_CLASS[code] = 1; // a-z
165
+ WORD_CP_CLASS[0x27] = 1; // '
166
+ WORD_CP_CLASS[0x2d] = 1; // -
167
+ WORD_CP_CLASS[0x5f] = 1; // _
168
+ WORD_CP_CLASS.fill(WORD_CP_SURROGATE, 0xd800, 0xe000);
169
+ const NON_ASCII_WORD_RE = /[\p{L}\p{N}]/u;
170
+ const ASTRAL_WORD_CP = new Map<number, 0 | 1>();
171
+
172
+ function astralWordMembership(codePoint: number): 0 | 1 {
173
+ const cached = ASTRAL_WORD_CP.get(codePoint);
174
+ if (cached !== undefined) return cached;
175
+ const membership: 0 | 1 = NON_ASCII_WORD_RE.test(String.fromCodePoint(codePoint)) ? 1 : 0;
176
+ ASTRAL_WORD_CP.set(codePoint, membership);
177
+ return membership;
178
+ }
179
+
180
+ /** Counts words as maximal runs of word characters (letters, digits,
181
+ * underscore, apostrophe, hyphen) — identical counts to a per-code-point
182
+ * `\p{L}\p{N}_'-` class test, in one table-driven pass with no allocations. */
152
183
  export function countWords(text: string): number {
184
+ const len = text.length;
153
185
  let count = 0;
154
- let inWord = false;
155
- for (const char of text) {
156
- const isWord = /[\p{L}\p{N}_'-]/u.test(char);
157
- if (isWord && !inWord) count++;
186
+ let inWord: number = 0;
187
+ for (let i = 0; i < len; i++) {
188
+ const code = text.charCodeAt(i);
189
+ const membership = WORD_CP_CLASS[code] ?? 0;
190
+ let isWord: number;
191
+ if (membership <= 1) {
192
+ isWord = membership;
193
+ } else if (membership === WORD_CP_SURROGATE) {
194
+ const next = i + 1 < len ? text.charCodeAt(i + 1) : 0;
195
+ if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
196
+ // Astral word characters (e.g. mathematical alphanumerics) count as
197
+ // one code point, exactly like the previous code-point iteration.
198
+ isWord = astralWordMembership(0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00));
199
+ i++; // consumed the pair's low surrogate half
200
+ } else {
201
+ isWord = 0; // lone surrogate half: never a word character
202
+ }
203
+ } else {
204
+ // First sight of this non-ASCII BMP code point: resolve once, memoize.
205
+ isWord = NON_ASCII_WORD_RE.test(String.fromCharCode(code)) ? 1 : 0;
206
+ WORD_CP_CLASS[code] = isWord;
207
+ }
208
+ count += isWord & (inWord ^ 1);
158
209
  inWord = isWord;
159
210
  }
160
211
  return count;
@@ -210,6 +261,9 @@ const BOX_DIVIDER_RIGHT = "┤";
210
261
  /** Dash run before the right corner when a right-side border label is present. */
211
262
  const BOX_LABELED_RIGHT_DASH_MIN = 3;
212
263
  const BOX_WIDTH_CACHE = new Map<string, number>();
264
+ /** Hard cap for BOX_WIDTH_CACHE; the oldest entry is evicted beyond this. Keys
265
+ * embed full bash commands, so the cache must stay bounded across a session. */
266
+ const BOX_WIDTH_CACHE_MAX_ENTRIES = 512;
213
267
 
214
268
  export function boxWidth(width: number): number {
215
269
  return Math.max(BOX_MIN_WIDTH, width);
@@ -236,6 +290,13 @@ function _tightBoxWidth(
236
290
  if (!widthKey) return measuredWidth;
237
291
  const cachedWidth = BOX_WIDTH_CACHE.get(widthKey) ?? 0;
238
292
  const nextWidth = Math.min(boxWidth(availableWidth), Math.max(cachedWidth, measuredWidth));
293
+ // Bounded LRU: Map preserves insertion order, so delete+set refreshes the
294
+ // key's recency and the first key is the oldest evict candidate.
295
+ BOX_WIDTH_CACHE.delete(widthKey);
296
+ if (BOX_WIDTH_CACHE.size >= BOX_WIDTH_CACHE_MAX_ENTRIES) {
297
+ const oldestKey = BOX_WIDTH_CACHE.keys().next().value;
298
+ if (oldestKey !== undefined) BOX_WIDTH_CACHE.delete(oldestKey);
299
+ }
239
300
  BOX_WIDTH_CACHE.set(widthKey, nextWidth);
240
301
  return nextWidth;
241
302
  }
@@ -244,6 +305,11 @@ export function boxedToolWidthKey(toolName: string, detail: string): string {
244
305
  return `${toolName}:${detail}`;
245
306
  }
246
307
 
308
+ /** Test-only debug view of the bounded box width cache. */
309
+ export function __getBoxWidthCacheDebugState(): { size: number } {
310
+ return { size: BOX_WIDTH_CACHE.size };
311
+ }
312
+
247
313
  export function formatToolName(toolName: string): string {
248
314
  const spaced = toolName
249
315
  .replace(/[_-]+/g, " ")
@@ -0,0 +1,98 @@
1
+ // Clipboard-paste path pattern (ADR 0009).
2
+ //
3
+ // Pi's built-in Ctrl+V (app.clipboard.pasteImage) materializes clipboard
4
+ // images as `<os.tmpdir()>/pi-clipboard-<uuid>.<ext>` files and inserts that
5
+ // absolute path into the editor as text. This module owns the shape of those
6
+ // artifacts so the editor feature (marker interception) and the messages
7
+ // feature (submit transform) share one definition without cross-feature
8
+ // imports (the pattern is a shared primitive).
9
+
10
+ import { tmpdir } from "node:os";
11
+
12
+ /** crypto.randomUUID() shape: lowercase v4, 8-4-4-4-12 hex groups. */
13
+ export const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
14
+
15
+ /** Extensions Pi's clipboard paste writes (extensionForImageMimeType / png). */
16
+ export const CLIPBOARD_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"] as const;
17
+
18
+ /** Marker inserted by the editor interception for a pending pasted image. */
19
+ export function clipboardImageMarker(index: number): string {
20
+ return `[Image #${index}]`;
21
+ }
22
+
23
+ /** `[Image #N]` marker token (editor interception; ADR 0009 marker surface). */
24
+ export const CLIPBOARD_IMAGE_MARKER_PATTERN = /\[Image #([0-9]+)\]/g;
25
+
26
+ function escapeRegExp(text: string): string {
27
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
+ }
29
+
30
+ /** Memoized regexes keyed by tmp root: tmpdir() call + escapeRegExp + regex
31
+ * compile happen once per root instead of on every submit/paste probe.
32
+ * Bounded because tests inject arbitrary roots — past the cap the oldest
33
+ * entry is evicted. Sharing one global instance per root is safe:
34
+ * String.matchAll clones the regex into its iterator (original untouched),
35
+ * and String.match/.replace with /g reset lastIndex themselves. */
36
+ const clipboardPathRegexCache = new Map<string, RegExp>();
37
+ const CLIPBOARD_PATH_REGEX_CACHE_MAX = 16;
38
+
39
+ /** Regex matching Pi clipboard-paste paths under the given tmpdir (default: process).
40
+ * Boundaries are non-alphanumeric (not whitespace-only): users type
41
+ * punctuation straight after a pasted path (`<path>, đây là...`), so a
42
+ * whitespace-or-EOL lookahead would miss the most common real shapes.
43
+ * Alphanumeric neighbors still reject glued text (`x<path>`). */
44
+ export function clipboardPathRegex(tmpRoot: string = tmpdir()): RegExp {
45
+ const cached = clipboardPathRegexCache.get(tmpRoot);
46
+ if (cached) return cached;
47
+ const exts = CLIPBOARD_IMAGE_EXTENSIONS.join("|");
48
+ const regex = new RegExp(
49
+ `(?<![a-zA-Z0-9])(${escapeRegExp(tmpRoot)}/pi-clipboard-${UUID_PATTERN}\\.(${exts}))(?![a-zA-Z0-9])`,
50
+ "g",
51
+ );
52
+ if (clipboardPathRegexCache.size >= CLIPBOARD_PATH_REGEX_CACHE_MAX) {
53
+ const oldest = clipboardPathRegexCache.keys().next().value;
54
+ if (oldest !== undefined) clipboardPathRegexCache.delete(oldest);
55
+ }
56
+ clipboardPathRegexCache.set(tmpRoot, regex);
57
+ return regex;
58
+ }
59
+
60
+ /** Test seam: clear the memoization cache (isolation between suites). */
61
+ export function resetClipboardPathRegexCache(): void {
62
+ clipboardPathRegexCache.clear();
63
+ }
64
+
65
+ /** Test seam: current memoized-regex count (memoization observability). */
66
+ export function __regexCacheSizeForTest(): number {
67
+ return clipboardPathRegexCache.size;
68
+ }
69
+
70
+ /** True when the whole string is exactly one clipboard-paste path token —
71
+ * the shape Pi's handleClipboardPaste passes to insertTextAtCursor. */
72
+ export function isSingleClipboardImagePath(text: string, tmpRoot?: string): boolean {
73
+ if (!text || /\s/.test(text)) return false;
74
+ const match = text.match(clipboardPathRegex(tmpRoot ?? tmpdir()));
75
+ return match !== null && match.length === 1 && match[0] === text;
76
+ }
77
+
78
+ /** All distinct clipboard-paste path tokens in the text (verbatim matches).
79
+ * `tmpRoot` defaults to the process tmpdir; tests inject a fixed root. */
80
+ export function extractClipboardImageTokens(text: string, tmpRoot?: string): string[] {
81
+ const tokens = new Set<string>();
82
+ for (const match of text.matchAll(clipboardPathRegex(tmpRoot ?? tmpdir()))) {
83
+ const token = match[1];
84
+ if (token) tokens.add(token);
85
+ }
86
+ return [...tokens];
87
+ }
88
+
89
+ const MARKER_AT_END_PATTERN = /\[Image #([0-9]+)\]( ?)$/;
90
+
91
+ /** When `text` ends with a clipboard image marker, return its index and total
92
+ * length (including the trailing space when present). Used by the editor's
93
+ * atomic backspace: the marker directly before the cursor deletes as a unit. */
94
+ export function clipboardMarkerAtEnd(text: string): { index: number; length: number } | undefined {
95
+ const match = MARKER_AT_END_PATTERN.exec(text);
96
+ if (!match) return undefined;
97
+ return { index: Number(match[1]), length: match[0].length };
98
+ }
@@ -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
+ }