@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.
@@ -0,0 +1,288 @@
1
+ // Inline previews for user-prompt images (ADR 0008).
2
+ //
3
+ // Absorbs the presentation half of @pi-archimedes/image-paste: images attached
4
+ // to the user's prompt render inline directly below the user message. The
5
+ // channel is a display-only CustomEntry (documented as "not sent to the LLM";
6
+ // ignored by buildSessionContext) — unlike image-paste's display-only custom
7
+ // *messages*, whose custom_message entries map into the session's context
8
+ // message list (sessionEntryToContextMessages) and would ride the context
9
+ // pipeline for the rest of the session.
10
+ //
11
+ // Ordering (verified against Pi 0.84.2): appending at `before_agent_start`
12
+ // lands the entry ABOVE the user message — the user message enters the feed
13
+ // only when UI listeners process `message_start(user)` and persists at
14
+ // `message_end(user)`, both AFTER extension handlers. So the append is staged
15
+ // at `before_agent_start` (the only event carrying the prompt's `images`) and
16
+ // flushed at the first `message_start(assistant)`: by then the user message is
17
+ // in the feed and persisted, and the host inserts the entry below it (spliced
18
+ // before the streaming component, or appended at the chat tail).
19
+ //
20
+ // Layout: `#N · WxH` label rows tie each image to its `[Image #N]` marker;
21
+ // multiple images render side-by-side on kitty-capable terminals (kitty
22
+ // graphics sequences are zero-width, so line zipping composes them), stacked
23
+ // elsewhere. Width is capped by `messages.previewMaxWidth` (default 30);
24
+ // Pi's global expansion (Ctrl+O) lifts the cap for a closer look.
25
+ //
26
+ // Fail-closed rules: unknown/malformed entry data renders zero lines (never
27
+ // an error box, never base64 leakage); a theme without fg disables the
28
+ // surface; the config leaf gates both the stage and the render side.
29
+
30
+ import {
31
+ type Component,
32
+ getCapabilities,
33
+ getImageDimensions,
34
+ Image,
35
+ type ImageDimensions,
36
+ } from "@earendil-works/pi-tui";
37
+ import { visibleWidth } from "../../shared/ansi.js";
38
+ import { getMessagesRenderConfig } from "./render-config.js";
39
+
40
+ /** Session-entry customType for user-prompt image previews. */
41
+ export const IMAGE_PREVIEW_ENTRY_TYPE = "pi-style-image-preview";
42
+
43
+ /** Expanded-state (Ctrl+O) width cap — closer look without config changes. */
44
+ export const IMAGE_PREVIEW_EXPANDED_MAX_WIDTH_CELLS = 60;
45
+
46
+ /** Hard bounds for the `messages.previewMaxWidth` leaf. */
47
+ export const IMAGE_PREVIEW_MIN_WIDTH_CELLS = 8;
48
+ export const IMAGE_PREVIEW_MAX_WIDTH_CELLS = 60;
49
+
50
+ /** Grid layout constants (kitty side-by-side). */
51
+ const GRID_GAP = 2;
52
+ const GRID_MIN_COLUMN = 14;
53
+
54
+ /** Persisted entry payload: base64 data + mime type per attached image. */
55
+ export interface ImagePreviewImage {
56
+ readonly data: string;
57
+ readonly mimeType: string;
58
+ }
59
+
60
+ export interface ImagePreviewEntryData {
61
+ readonly images: readonly ImagePreviewImage[];
62
+ }
63
+
64
+ /**
65
+ * Structural port over the host APIs this surface uses. The pi/ layer passes
66
+ * the ExtensionAPI; tests pass fakes.
67
+ */
68
+ export interface ImagePreviewHostPort {
69
+ registerEntryRenderer(
70
+ customType: string,
71
+ renderer: (entry: unknown, options: unknown, theme: unknown) => unknown,
72
+ ): void;
73
+ appendEntry(customType: string, data?: unknown): void;
74
+ }
75
+
76
+ function isPreviewableImage(value: unknown): value is ImagePreviewImage {
77
+ if (!value || typeof value !== "object") return false;
78
+ const data = (value as { data?: unknown }).data;
79
+ const mimeType = (value as { mimeType?: unknown }).mimeType;
80
+ return typeof data === "string" && data.length > 0 && typeof mimeType === "string" && mimeType.length > 0;
81
+ }
82
+
83
+ /**
84
+ * Entry-data view of a prompt's attached images: drops malformed blocks,
85
+ * returns undefined when nothing previewable remains (no entry is appended).
86
+ * Config-gated (`messages.showImagePreviews`).
87
+ */
88
+ export function stageImagePreviewData(images: readonly unknown[]): ImagePreviewEntryData | undefined {
89
+ if (!getMessagesRenderConfig().showImagePreviews) return undefined;
90
+ const valid = images.filter(isPreviewableImage).map((image) => ({ data: image.data, mimeType: image.mimeType }));
91
+ return valid.length > 0 ? { images: valid } : undefined;
92
+ }
93
+
94
+ /** Flush a staged preview as a session entry (display-only CustomEntry). */
95
+ export function flushImagePreviewEntry(pi: ImagePreviewHostPort, data: ImagePreviewEntryData): void {
96
+ if (!getMessagesRenderConfig().showImagePreviews) return;
97
+ pi.appendEntry(IMAGE_PREVIEW_ENTRY_TYPE, data);
98
+ }
99
+
100
+ /** Validated images from a persisted entry's data field; undefined = malformed. */
101
+ function previewImagesFromEntryData(data: unknown): readonly ImagePreviewImage[] | undefined {
102
+ if (!data || typeof data !== "object") return undefined;
103
+ const images = (data as { images?: unknown }).images;
104
+ if (!Array.isArray(images) || images.length === 0) return undefined;
105
+ if (!images.every(isPreviewableImage)) return undefined;
106
+ return images;
107
+ }
108
+
109
+ type FgColor = (color: string, text: string) => string;
110
+
111
+ /** `#N` label for entry position N (1-based — matches `[Image #N]` markers).
112
+ * Dimensions arrive pre-parsed (parseImageDimensions) — no decode here. */
113
+ function imageLabel(theme: { fg: FgColor }, index: number, dims?: ImageDimensions): string {
114
+ const dimsPart = dims ? ` · ${dims.widthPx}×${dims.heightPx}` : "";
115
+ return theme.fg("dim", `#${index}${dimsPart}`);
116
+ }
117
+
118
+ /** Test hook: counts parseImageDimensions invocations (entry-reuse contract). */
119
+ export const __dimensionParsesForTest = { count: 0 };
120
+
121
+ /** Parse image dimensions from a base64 PREFIX first — 256 chars (a whole
122
+ * number of base64 blocks, ~192 bytes) covers the PNG/GIF/WebP headers and
123
+ * the usual early JPEG SOF marker. Only when the prefix fails (rare — a
124
+ * late JPEG SOF) do we decode the full payload. Avoids decoding a ~1.3MB
125
+ * screenshot just to read ~24 header bytes on every rebuild. */
126
+ function parseImageDimensions(image: ImagePreviewImage): ImageDimensions | undefined {
127
+ __dimensionParsesForTest.count++;
128
+ const fromPrefix = getImageDimensions(image.data.slice(0, 256), image.mimeType);
129
+ return fromPrefix ?? getImageDimensions(image.data, image.mimeType) ?? undefined;
130
+ }
131
+
132
+ /** Per-entry render artifacts (components, labels). */
133
+ interface PreviewCache {
134
+ readonly images: readonly ImagePreviewImage[];
135
+ readonly components: Image[];
136
+ readonly labels: string[];
137
+ }
138
+
139
+ /** The host's CustomEntryComponent re-invokes the renderer on every resize,
140
+ * Ctrl+O toggle, and cell-size response — always with the SAME entry object
141
+ * (rebuild() passes this.entry). Keying on that object lets Image components
142
+ * keep their internal line caches and kitty image ids (no re-chunking, no id
143
+ * churn) and labels their parsed dimensions across rebuilds. */
144
+ const previewCache = new WeakMap<object, PreviewCache>();
145
+
146
+ /**
147
+ * Entry renderer for `pi-style-image-preview` entries. Returns undefined
148
+ * (zero-line render) when the surface is disabled, the entry data is
149
+ * malformed, or the theme lacks fg — never throws, never leaks base64 into a
150
+ * rendered line (the pi-tui Image fallback vocabulary is mime + dimensions).
151
+ */
152
+ export function renderImagePreviewEntry(entry: unknown, options: unknown, theme: unknown): Component | undefined {
153
+ if (!getMessagesRenderConfig().showImagePreviews) return undefined;
154
+ const images = previewImagesFromEntryData((entry as { data?: unknown } | null | undefined)?.data);
155
+ if (!images) return undefined;
156
+ const fg = (theme as { fg?: FgColor } | null | undefined)?.fg;
157
+ if (typeof fg !== "function") return undefined;
158
+ const expanded = (options as { expanded?: boolean } | null | undefined)?.expanded === true;
159
+ try {
160
+ const themed = { fg: fg.bind(theme) } as { fg: FgColor };
161
+ let cached = previewCache.get(entry as object);
162
+ if (!cached) {
163
+ const dims = images.map(parseImageDimensions);
164
+ const components = images.map(
165
+ (image, index) =>
166
+ new Image(
167
+ image.data,
168
+ image.mimeType,
169
+ { fallbackColor: (text: string) => themed.fg("toolOutput", text) },
170
+ {},
171
+ dims[index],
172
+ ),
173
+ );
174
+ const labels = images.map((_image, index) => imageLabel(themed, index + 1, dims[index]));
175
+ cached = { images, components, labels };
176
+ previewCache.set(entry as object, cached);
177
+ }
178
+ const { components, labels } = cached;
179
+ // Memoized output: render passes tick on every streaming update; the
180
+ // same width/expansion/config/capability key returns the SAME array
181
+ // reference, skipping the grid re-zip and the host's per-line diff.
182
+ let memo: { key: string; lines: string[] } | undefined;
183
+ return {
184
+ invalidate() {
185
+ memo = undefined;
186
+ for (const component of components) component.invalidate();
187
+ },
188
+ render(width: number): string[] {
189
+ const config = getMessagesRenderConfig();
190
+ const kitty = getCapabilities().images === "kitty";
191
+ const key = `${width}|${expanded}|${config.previewMaxWidth}|${config.showImagePreviews}|${kitty}`;
192
+ if (memo && memo.key === key) return memo.lines;
193
+ const maxWidth = expanded
194
+ ? IMAGE_PREVIEW_EXPANDED_MAX_WIDTH_CELLS
195
+ : Math.min(config.previewMaxWidth, IMAGE_PREVIEW_MAX_WIDTH_CELLS);
196
+ let lines: string[];
197
+ if (width <= 0) lines = [];
198
+ else if (images.length === 1 || !kitty) lines = renderStacked(width, maxWidth, labels, components);
199
+ else lines = renderGrid(width, maxWidth, labels, components);
200
+ memo = { key, lines };
201
+ return lines;
202
+ },
203
+ };
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ /** One labeled image per block, a blank line between (also the fallback path). */
210
+ function renderStacked(width: number, maxWidth: number, labels: string[], components: Image[]): string[] {
211
+ const lines: string[] = [];
212
+ for (const [index, component] of components.entries()) {
213
+ if (index > 0) lines.push("");
214
+ lines.push(labels[index] ?? "");
215
+ lines.push(...component.render(Math.min(width, Math.max(1, maxWidth + 2))));
216
+ }
217
+ return lines;
218
+ }
219
+
220
+ /** Strip trailing spaces without scanning the whole line: kitty rows always
221
+ * end with the `ESC \` terminator (never trailing spaces), so the fast path
222
+ * is one endsWith check; only space-padded rows pay for the strip. */
223
+ function stripTrailingSpaces(line: string): string {
224
+ if (!line.endsWith(" ")) return line;
225
+ let end = line.length;
226
+ while (end > 0 && line.charCodeAt(end - 1) === 0x20) end--;
227
+ return line.slice(0, end);
228
+ }
229
+
230
+ /**
231
+ * Side-by-side grid (kitty graphics only — sequences are zero-width so
232
+ * per-row line zipping composes columns). Columns shrink to fit the width;
233
+ * when fewer than two usable columns fit, this degrades to stacked.
234
+ */
235
+ function renderGrid(width: number, maxWidth: number, labels: string[], components: Image[]): string[] {
236
+ const usable = Math.max(1, width - 2);
237
+ const maxColumns = Math.floor((usable + GRID_GAP) / (GRID_MIN_COLUMN + GRID_GAP));
238
+ const columns = Math.max(1, Math.min(components.length, maxColumns, 3));
239
+ if (columns < 2) return renderStacked(width, maxWidth, labels, components);
240
+ const columnWidth = Math.min(maxWidth, Math.floor((usable - (columns - 1) * GRID_GAP) / columns));
241
+ if (columnWidth < GRID_MIN_COLUMN) return renderStacked(width, maxWidth, labels, components);
242
+
243
+ const lines: string[] = [];
244
+ for (let start = 0; start < components.length; start += columns) {
245
+ const group = components.slice(start, start + columns);
246
+ const groupLabels = labels.slice(start, start + columns);
247
+ if (start > 0) lines.push("");
248
+ const rendered = group.map((component) => component.render(columnWidth));
249
+ // Label row: each label padded to its column width, gap between.
250
+ const labelRow = groupLabels
251
+ .map((label, _index) => {
252
+ const pad = Math.max(0, columnWidth - visibleWidth(stripFormatting(label)));
253
+ return label + " ".repeat(pad);
254
+ })
255
+ .join(" ".repeat(GRID_GAP))
256
+ .trimEnd();
257
+ lines.push(labelRow);
258
+ // Image rows: zip columns line by line. Kitty places each image at the
259
+ // CURSOR position when its transmission completes, and the sequences are
260
+ // zero-width — so after image 1 the cursor is still at column 0 and the
261
+ // second transmission would land ON TOP of image 1. Each subsequent
262
+ // column therefore starts with a CHA jump (`ESC[<col>G`, 1-based) to its
263
+ // start column; terminal/herdr cursor tracking follows, and both images
264
+ // composite side by side.
265
+ const rows = Math.max(...rendered.map((column) => column.length));
266
+ for (let row = 0; row < rows; row++) {
267
+ let line = "";
268
+ for (let column = 0; column < rendered.length; column++) {
269
+ if (column > 0) {
270
+ const startCol = column * (columnWidth + GRID_GAP);
271
+ line += `\x1b[${startCol + 1}G`;
272
+ }
273
+ line += rendered[column]?.[row] ?? "";
274
+ }
275
+ lines.push(stripTrailingSpaces(line));
276
+ }
277
+ }
278
+ return lines;
279
+ }
280
+
281
+ /** Label text may carry ANSI color; measure only the visible part.
282
+ * Reuses the shared ANSI-stripping utility (no local control-char regex). */
283
+ import { stripAnsi as stripFormatting } from "../../shared/ansi.js";
284
+
285
+ /** Register the entry renderer (extension load; before any entry can exist). */
286
+ export function registerImagePreviewSurface(pi: ImagePreviewHostPort): void {
287
+ pi.registerEntryRenderer(IMAGE_PREVIEW_ENTRY_TYPE, renderImagePreviewEntry);
288
+ }
@@ -0,0 +1,36 @@
1
+ // Per-session render configuration for message-surface features.
2
+ //
3
+ // Mirrors the tools surface's session-config pattern (features/tools/boxed/
4
+ // session-config.ts): set once per session by the compatibility coordinator,
5
+ // read inside renderers, and kept out of the render path (no filesystem or
6
+ // config reads during render).
7
+
8
+ export interface MessagesRenderConfig {
9
+ /** Inline previews for user-prompt images (ADR 0008). Gates both the
10
+ * stage side (before_agent_start) and the render side (entry renderer). */
11
+ showImagePreviews: boolean;
12
+ /** Clipboard image input (ADR 0009): upgrade built-in paste temp paths to
13
+ * real image attachments on submit. Gates the input transform only. */
14
+ clipboardImages: boolean;
15
+ /** Max cell width per preview image (ADR 0008); Ctrl+O expansion uses 60. */
16
+ previewMaxWidth: number;
17
+ }
18
+
19
+ let sessionMessagesConfig: MessagesRenderConfig = {
20
+ showImagePreviews: true,
21
+ clipboardImages: true,
22
+ previewMaxWidth: 30,
23
+ };
24
+
25
+ export function setMessagesRenderConfig(config: Partial<MessagesRenderConfig>): void {
26
+ sessionMessagesConfig = { ...sessionMessagesConfig, ...config };
27
+ }
28
+
29
+ export function getMessagesRenderConfig(): MessagesRenderConfig {
30
+ return sessionMessagesConfig;
31
+ }
32
+
33
+ /** Reset to defaults (test isolation). */
34
+ export function resetMessagesRenderConfig(): void {
35
+ sessionMessagesConfig = { showImagePreviews: true, clipboardImages: true, previewMaxWidth: 30 };
36
+ }
@@ -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,
@@ -64,6 +71,27 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
64
71
  pi.registerFlag("pi-style-ascii", { type: "boolean", description: "Use ASCII pi-style markers" });
65
72
  const coordinator = createPiStyleSessionCoordinator(pi, compatibilityTestHooks);
66
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
+ });
67
95
  pi.on("session_start", async (event, ctx) => {
68
96
  resetUsageFromSessionCache(ctx.sessionManager);
69
97
  // Pi only activates read/bash/edit/write by default; grep/find/ls are
@@ -81,7 +109,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
81
109
  // (user request → agent_end), not pi's per-message turn_end.
82
110
  beginAgentRun();
83
111
  });
84
- pi.on("input", (event, _ctx) => {
112
+ pi.on("input", async (event, _ctx) => {
85
113
  coordinator.app.runtime.current?.dismissStartup();
86
114
  // Bare `!`/`!!` submit guard: Pi treats `!`-prefixed input as a direct bash
87
115
  // command but falls through to normal message submission when the bang has
@@ -97,6 +125,23 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
97
125
  if (trimmed.slice(bangLength).trim() === "") return { action: "handled" };
98
126
  }
99
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
+ }
100
145
  return undefined;
101
146
  });
102
147
  pi.on("tool_execution_start", () => coordinator.app.runtime.current?.dismissStartup());
@@ -114,10 +159,16 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
114
159
  pi.on("session_info_changed", (event) =>
115
160
  coordinator.app.update({ sessionName: event.name }, "coalesced", { refreshExtensionStatuses: true }),
116
161
  );
117
- pi.on("message_start", () => {
162
+ pi.on("message_start", (event) => {
118
163
  // A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
119
164
  // new message start a fresh batch instead of joining the previous one.
120
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
+ }
121
172
  // grep/bash tree panels are NOT cleared here: historical panels must keep
122
173
  // their state so Pi re-renders of previous messages (scroll/resume) stay
123
174
  // intact. Only session boundaries reset them (session-coordinator).
@@ -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();
@@ -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
+ }