@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,335 @@
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
+ // Kitty slots use the actual rendered image width to avoid portrait-image gaps;
25
+ // strongly height-mismatched images stack instead of leaving empty rows. Pi's
26
+ // global expansion (Ctrl+O) lifts the cap for a closer look.
27
+ //
28
+ // Fail-closed rules: unknown/malformed entry data renders zero lines (never
29
+ // an error box, never base64 leakage); a theme without fg disables the
30
+ // surface; the config leaf gates both the stage and the render side.
31
+
32
+ import {
33
+ type CellDimensions,
34
+ type Component,
35
+ getCapabilities,
36
+ getCellDimensions,
37
+ getImageDimensions,
38
+ Image,
39
+ type ImageDimensions,
40
+ } from "@earendil-works/pi-tui";
41
+ import { visibleWidth } from "../../shared/ansi.js";
42
+ import { getMessagesRenderConfig } from "./render-config.js";
43
+
44
+ /** Session-entry customType for user-prompt image previews. */
45
+ export const IMAGE_PREVIEW_ENTRY_TYPE = "pi-style-image-preview";
46
+
47
+ /** Expanded-state (Ctrl+O) width cap — closer look without config changes. */
48
+ export const IMAGE_PREVIEW_EXPANDED_MAX_WIDTH_CELLS = 60;
49
+
50
+ /** Hard bounds for the `messages.previewMaxWidth` leaf. */
51
+ export const IMAGE_PREVIEW_MIN_WIDTH_CELLS = 8;
52
+ export const IMAGE_PREVIEW_MAX_WIDTH_CELLS = 60;
53
+
54
+ /** Grid layout constants (kitty side-by-side). */
55
+ const GRID_GAP = 2;
56
+ const GRID_MIN_COLUMN = 14;
57
+ const GRID_MAX_HEIGHT_RATIO = 1.5;
58
+ const FALLBACK_IMAGE_DIMENSIONS: ImageDimensions = { widthPx: 800, heightPx: 600 };
59
+
60
+ /** Persisted entry payload: base64 data + mime type per attached image. */
61
+ export interface ImagePreviewImage {
62
+ readonly data: string;
63
+ readonly mimeType: string;
64
+ }
65
+
66
+ export interface ImagePreviewEntryData {
67
+ readonly images: readonly ImagePreviewImage[];
68
+ }
69
+
70
+ /**
71
+ * Structural port over the host APIs this surface uses. The pi/ layer passes
72
+ * the ExtensionAPI; tests pass fakes.
73
+ */
74
+ export interface ImagePreviewHostPort {
75
+ registerEntryRenderer(
76
+ customType: string,
77
+ renderer: (entry: unknown, options: unknown, theme: unknown) => unknown,
78
+ ): void;
79
+ appendEntry(customType: string, data?: unknown): void;
80
+ }
81
+
82
+ function isPreviewableImage(value: unknown): value is ImagePreviewImage {
83
+ if (!value || typeof value !== "object") return false;
84
+ const data = (value as { data?: unknown }).data;
85
+ const mimeType = (value as { mimeType?: unknown }).mimeType;
86
+ return typeof data === "string" && data.length > 0 && typeof mimeType === "string" && mimeType.length > 0;
87
+ }
88
+
89
+ /**
90
+ * Entry-data view of a prompt's attached images: drops malformed blocks,
91
+ * returns undefined when nothing previewable remains (no entry is appended).
92
+ * Config-gated (`messages.showImagePreviews`).
93
+ */
94
+ export function stageImagePreviewData(images: readonly unknown[]): ImagePreviewEntryData | undefined {
95
+ if (!getMessagesRenderConfig().showImagePreviews) return undefined;
96
+ const valid = images.filter(isPreviewableImage).map((image) => ({ data: image.data, mimeType: image.mimeType }));
97
+ return valid.length > 0 ? { images: valid } : undefined;
98
+ }
99
+
100
+ /** Flush a staged preview as a session entry (display-only CustomEntry). */
101
+ export function flushImagePreviewEntry(pi: ImagePreviewHostPort, data: ImagePreviewEntryData): void {
102
+ if (!getMessagesRenderConfig().showImagePreviews) return;
103
+ pi.appendEntry(IMAGE_PREVIEW_ENTRY_TYPE, data);
104
+ }
105
+
106
+ /** Validated images from a persisted entry's data field; undefined = malformed. */
107
+ function previewImagesFromEntryData(data: unknown): readonly ImagePreviewImage[] | undefined {
108
+ if (!data || typeof data !== "object") return undefined;
109
+ const images = (data as { images?: unknown }).images;
110
+ if (!Array.isArray(images) || images.length === 0) return undefined;
111
+ if (!images.every(isPreviewableImage)) return undefined;
112
+ return images;
113
+ }
114
+
115
+ type FgColor = (color: string, text: string) => string;
116
+
117
+ /** `#N` label for entry position N (1-based — matches `[Image #N]` markers).
118
+ * Dimensions arrive pre-parsed (parseImageDimensions) — no decode here. */
119
+ function imageLabel(theme: { fg: FgColor }, index: number, dims?: ImageDimensions): string {
120
+ const dimsPart = dims ? ` · ${dims.widthPx}×${dims.heightPx}` : "";
121
+ return theme.fg("dim", `#${index}${dimsPart}`);
122
+ }
123
+
124
+ /** Test hook: counts parseImageDimensions invocations (entry-reuse contract). */
125
+ export const __dimensionParsesForTest = { count: 0 };
126
+
127
+ /** Parse image dimensions from a base64 PREFIX first — 256 chars (a whole
128
+ * number of base64 blocks, ~192 bytes) covers the PNG/GIF/WebP headers and
129
+ * the usual early JPEG SOF marker. Only when the prefix fails (rare — a
130
+ * late JPEG SOF) do we decode the full payload. Avoids decoding a ~1.3MB
131
+ * screenshot just to read ~24 header bytes on every rebuild. */
132
+ function parseImageDimensions(image: ImagePreviewImage): ImageDimensions | undefined {
133
+ __dimensionParsesForTest.count++;
134
+ const fromPrefix = getImageDimensions(image.data.slice(0, 256), image.mimeType);
135
+ return fromPrefix ?? getImageDimensions(image.data, image.mimeType) ?? undefined;
136
+ }
137
+
138
+ /** Per-entry render artifacts (components, labels). */
139
+ interface PreviewCache {
140
+ readonly images: readonly ImagePreviewImage[];
141
+ readonly dimensions: readonly (ImageDimensions | undefined)[];
142
+ readonly components: Image[];
143
+ readonly labels: string[];
144
+ }
145
+
146
+ /** The host's CustomEntryComponent re-invokes the renderer on every resize,
147
+ * Ctrl+O toggle, and cell-size response — always with the SAME entry object
148
+ * (rebuild() passes this.entry). Keying on that object lets Image components
149
+ * keep their internal line caches and kitty image ids (no re-chunking, no id
150
+ * churn) and labels their parsed dimensions across rebuilds. */
151
+ const previewCache = new WeakMap<object, PreviewCache>();
152
+
153
+ /**
154
+ * Entry renderer for `pi-style-image-preview` entries. Returns undefined
155
+ * (zero-line render) when the surface is disabled, the entry data is
156
+ * malformed, or the theme lacks fg — never throws, never leaks base64 into a
157
+ * rendered line (the pi-tui Image fallback vocabulary is mime + dimensions).
158
+ */
159
+ export function renderImagePreviewEntry(entry: unknown, options: unknown, theme: unknown): Component | undefined {
160
+ if (!getMessagesRenderConfig().showImagePreviews) return undefined;
161
+ const images = previewImagesFromEntryData((entry as { data?: unknown } | null | undefined)?.data);
162
+ if (!images) return undefined;
163
+ const fg = (theme as { fg?: FgColor } | null | undefined)?.fg;
164
+ if (typeof fg !== "function") return undefined;
165
+ const expanded = (options as { expanded?: boolean } | null | undefined)?.expanded === true;
166
+ try {
167
+ const themed = { fg: fg.bind(theme) } as { fg: FgColor };
168
+ let cached = previewCache.get(entry as object);
169
+ if (!cached) {
170
+ const dims = images.map(parseImageDimensions);
171
+ const components = images.map(
172
+ (image, index) =>
173
+ new Image(
174
+ image.data,
175
+ image.mimeType,
176
+ { fallbackColor: (text: string) => themed.fg("toolOutput", text) },
177
+ {},
178
+ dims[index],
179
+ ),
180
+ );
181
+ const labels = images.map((_image, index) => imageLabel(themed, index + 1, dims[index]));
182
+ cached = { images, dimensions: dims, components, labels };
183
+ previewCache.set(entry as object, cached);
184
+ }
185
+ const { components, dimensions, labels } = cached;
186
+ // Memoized output: render passes tick on every streaming update; the
187
+ // same width/expansion/config/capability key returns the SAME array
188
+ // reference, skipping the grid re-zip and the host's per-line diff.
189
+ let memo: { key: string; lines: string[] } | undefined;
190
+ return {
191
+ invalidate() {
192
+ memo = undefined;
193
+ for (const component of components) component.invalidate();
194
+ },
195
+ render(width: number): string[] {
196
+ const config = getMessagesRenderConfig();
197
+ const kitty = getCapabilities().images === "kitty";
198
+ const key = `${width}|${expanded}|${config.previewMaxWidth}|${config.showImagePreviews}|${kitty}`;
199
+ if (memo && memo.key === key) return memo.lines;
200
+ const maxWidth = expanded
201
+ ? IMAGE_PREVIEW_EXPANDED_MAX_WIDTH_CELLS
202
+ : Math.min(config.previewMaxWidth, IMAGE_PREVIEW_MAX_WIDTH_CELLS);
203
+ let lines: string[];
204
+ if (width <= 0) lines = [];
205
+ else if (images.length === 1 || !kitty) lines = renderStacked(width, maxWidth, labels, components);
206
+ else lines = renderGrid(width, maxWidth, labels, components, dimensions);
207
+ memo = { key, lines };
208
+ return lines;
209
+ },
210
+ };
211
+ } catch {
212
+ return undefined;
213
+ }
214
+ }
215
+
216
+ /** One labeled image per block, a blank line between (also the fallback path). */
217
+ function renderStacked(width: number, maxWidth: number, labels: string[], components: Image[]): string[] {
218
+ const lines: string[] = [];
219
+ for (const [index, component] of components.entries()) {
220
+ if (index > 0) lines.push("");
221
+ lines.push(labels[index] ?? "");
222
+ lines.push(...component.render(Math.min(width, Math.max(1, maxWidth + 2))));
223
+ }
224
+ return lines;
225
+ }
226
+
227
+ /** Strip trailing spaces without scanning the whole line: kitty rows always
228
+ * end with the `ESC \` terminator (never trailing spaces), so the fast path
229
+ * is one endsWith check; only space-padded rows pay for the strip. */
230
+ function stripTrailingSpaces(line: string): string {
231
+ if (!line.endsWith(" ")) return line;
232
+ let end = line.length;
233
+ while (end > 0 && line.charCodeAt(end - 1) === 0x20) end--;
234
+ return line.slice(0, end);
235
+ }
236
+
237
+ /**
238
+ * Side-by-side grid (kitty graphics only — sequences are zero-width so
239
+ * per-row line zipping composes columns). Columns shrink to fit the width;
240
+ * compact slots follow actual image widths; strongly height-mismatched groups
241
+ * degrade to stacked. When fewer than two usable columns fit, this also
242
+ * degrades to stacked.
243
+ */
244
+ function renderGrid(
245
+ width: number,
246
+ maxWidth: number,
247
+ labels: string[],
248
+ components: Image[],
249
+ dimensions: readonly (ImageDimensions | undefined)[],
250
+ ): string[] {
251
+ const usable = Math.max(1, width - 2);
252
+ const maxColumns = Math.floor((usable + GRID_GAP) / (GRID_MIN_COLUMN + GRID_GAP));
253
+ const columns = Math.max(1, Math.min(components.length, maxColumns, 3));
254
+ if (columns < 2) return renderStacked(width, maxWidth, labels, components);
255
+ const columnWidth = Math.min(maxWidth, Math.floor((usable - (columns - 1) * GRID_GAP) / columns));
256
+ if (columnWidth < GRID_MIN_COLUMN) return renderStacked(width, maxWidth, labels, components);
257
+
258
+ const lines: string[] = [];
259
+ const cellDimensions = getCellDimensions();
260
+ for (let start = 0; start < components.length; start += columns) {
261
+ const group = components.slice(start, start + columns);
262
+ const groupLabels = labels.slice(start, start + columns);
263
+ const groupDimensions = dimensions.slice(start, start + columns);
264
+ if (start > 0) lines.push("");
265
+ const rendered = group.map((component) => component.render(columnWidth));
266
+ const sizes = groupDimensions.map((imageDimensions) => imageCellSize(imageDimensions, columnWidth, cellDimensions));
267
+
268
+ // Avoid keeping a short neighbor's column alive for a much taller image.
269
+ // Similar screenshots remain side-by-side; strongly mismatched images stack.
270
+ const rowCounts = rendered.map((column) => column.length).filter((count) => count > 0);
271
+ const minRows = Math.min(...rowCounts);
272
+ const maxRows = Math.max(...rowCounts);
273
+ if (rowCounts.length > 1 && minRows > 0 && maxRows >= minRows * GRID_MAX_HEIGHT_RATIO) {
274
+ return renderStacked(width, maxWidth, labels, components);
275
+ }
276
+
277
+ // Use actual image widths for the slots instead of the shared max width.
278
+ // This removes unused horizontal space around narrow portrait images.
279
+ const slotWidths = groupLabels.map((label, index) =>
280
+ Math.max(sizes[index]?.columns ?? 1, visibleWidth(stripFormatting(label))),
281
+ );
282
+ const labelRow = groupLabels
283
+ .map((label, index) => {
284
+ const pad = Math.max(0, (slotWidths[index] ?? 0) - visibleWidth(stripFormatting(label)));
285
+ return label + " ".repeat(pad);
286
+ })
287
+ .join(" ".repeat(GRID_GAP))
288
+ .trimEnd();
289
+ lines.push(labelRow);
290
+
291
+ // Kitty sequences are zero-width, so each image starts with a CHA jump.
292
+ // The offset is based on cumulative compact slot widths, not columnWidth.
293
+ const rows = Math.max(...rendered.map((column) => column.length));
294
+ for (let row = 0; row < rows; row++) {
295
+ let line = "";
296
+ let startCol = 0;
297
+ for (let column = 0; column < rendered.length; column++) {
298
+ if (column > 0) line += `\x1b[${startCol + 1}G`;
299
+ line += rendered[column]?.[row] ?? "";
300
+ startCol += (slotWidths[column] ?? 0) + GRID_GAP;
301
+ }
302
+ lines.push(stripTrailingSpaces(line));
303
+ }
304
+ }
305
+ return lines;
306
+ }
307
+
308
+ /** Match pi-tui Image.render's Kitty cell-size calculation for slot sizing. */
309
+ function imageCellSize(
310
+ dimensions: ImageDimensions | undefined,
311
+ requestedWidth: number,
312
+ cellDimensions: CellDimensions,
313
+ ): { columns: number; rows: number } {
314
+ const imageDimensions = dimensions ?? FALLBACK_IMAGE_DIMENSIONS;
315
+ const maxWidth = Math.max(1, Math.min(requestedWidth - 2, 60));
316
+ const maxHeight = Math.max(1, Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx));
317
+ const imageWidth = Math.max(1, imageDimensions.widthPx);
318
+ const imageHeight = Math.max(1, imageDimensions.heightPx);
319
+ const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth;
320
+ const heightScale = (maxHeight * cellDimensions.heightPx) / imageHeight;
321
+ const scale = Math.min(widthScale, heightScale);
322
+ return {
323
+ columns: Math.max(1, Math.min(maxWidth, Math.ceil((imageWidth * scale) / cellDimensions.widthPx))),
324
+ rows: Math.max(1, Math.min(maxHeight, Math.ceil((imageHeight * scale) / cellDimensions.heightPx))),
325
+ };
326
+ }
327
+
328
+ /** Label text may carry ANSI color; measure only the visible part.
329
+ * Reuses the shared ANSI-stripping utility (no local control-char regex). */
330
+ import { stripAnsi as stripFormatting } from "../../shared/ansi.js";
331
+
332
+ /** Register the entry renderer (extension load; before any entry can exist). */
333
+ export function registerImagePreviewSurface(pi: ImagePreviewHostPort): void {
334
+ pi.registerEntryRenderer(IMAGE_PREVIEW_ENTRY_TYPE, renderImagePreviewEntry);
335
+ }
@@ -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,26 @@ 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): the prompt's `images` are only
80
+ // available at `before_agent_start`, but appending there would place the entry
81
+ // above the user message because extension handlers run before the UI handles
82
+ // `message_start(user)`. Capture there, then flush on the next event-loop turn
83
+ // after `message_start(user)` so the preview appears immediately below it.
84
+ // The assistant-start handler is a synchronous fallback for runtimes that begin
85
+ // the assistant before the deferred flush runs. A steered prompt arriving before
86
+ // flush replaces the staged slot (last write wins).
87
+ let stagedImagePreview: ImagePreviewEntryData | undefined;
88
+ let previewFlushTimer: ReturnType<typeof setTimeout> | undefined;
89
+ pi.on("before_agent_start", (event) => {
90
+ // Always replace the slot: a subsequent image-less prompt must not
91
+ // accidentally flush the previous prompt's preview.
92
+ stagedImagePreview = stageImagePreviewData(event.images ?? []);
93
+ });
67
94
  pi.on("session_start", async (event, ctx) => {
68
95
  resetUsageFromSessionCache(ctx.sessionManager);
69
96
  // Pi only activates read/bash/edit/write by default; grep/find/ls are
@@ -81,7 +108,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
81
108
  // (user request → agent_end), not pi's per-message turn_end.
82
109
  beginAgentRun();
83
110
  });
84
- pi.on("input", (event, _ctx) => {
111
+ pi.on("input", async (event, _ctx) => {
85
112
  coordinator.app.runtime.current?.dismissStartup();
86
113
  // Bare `!`/`!!` submit guard: Pi treats `!`-prefixed input as a direct bash
87
114
  // command but falls through to normal message submission when the bang has
@@ -97,6 +124,23 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
97
124
  if (trimmed.slice(bangLength).trim() === "") return { action: "handled" };
98
125
  }
99
126
  }
127
+ // Clipboard image input (ADR 0009): `[Image #N]` markers resolve for EVERY
128
+ // submit source — a submit is a submit (image-paste semantics); consuming
129
+ // the registry one-shot on rpc/extension submits too keeps a pasted marker
130
+ // from dangling into a later, unrelated prompt. No-op when the registry is
131
+ // empty. Raw clipboard path tokens, in contrast, only ever come from the
132
+ // interactive editor's built-in paste — that upgrade stays interactive-only.
133
+ // Combined images flow on to before_agent_start, where the ADR 0008 preview
134
+ // entry picks them up.
135
+ const markerResult = await resolvePendingImageMarkers(event.text);
136
+ const pathResult = event.source === "interactive" ? await transformClipboardImages(event.text) : undefined;
137
+ if (markerResult || pathResult) {
138
+ return {
139
+ action: "transform" as const,
140
+ text: pathResult?.text ?? event.text,
141
+ images: [...(markerResult?.images ?? []), ...(pathResult?.images ?? [])],
142
+ };
143
+ }
100
144
  return undefined;
101
145
  });
102
146
  pi.on("tool_execution_start", () => coordinator.app.runtime.current?.dismissStartup());
@@ -114,10 +158,32 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
114
158
  pi.on("session_info_changed", (event) =>
115
159
  coordinator.app.update({ sessionName: event.name }, "coalesced", { refreshExtensionStatuses: true }),
116
160
  );
117
- pi.on("message_start", () => {
161
+ pi.on("message_start", (event) => {
118
162
  // A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
119
163
  // new message start a fresh batch instead of joining the previous one.
120
164
  closeActiveBatch();
165
+ if (event.message?.role === "user" && stagedImagePreview && !previewFlushTimer) {
166
+ // Extension handlers run before Pi's interactive listener adds the user
167
+ // message to the chat. Defer one turn so the entry is appended below the
168
+ // visible user message, but do not wait for assistant content to arrive.
169
+ const pending = stagedImagePreview;
170
+ previewFlushTimer = setTimeout(() => {
171
+ previewFlushTimer = undefined;
172
+ if (stagedImagePreview !== pending) return;
173
+ flushImagePreviewEntry(pi, pending);
174
+ stagedImagePreview = undefined;
175
+ }, 0);
176
+ }
177
+ // Fallback for runtimes that start the assistant before the deferred turn
178
+ // runs. This preserves the ordering below the user and never duplicates.
179
+ if (stagedImagePreview && event.message?.role === "assistant") {
180
+ if (previewFlushTimer) {
181
+ clearTimeout(previewFlushTimer);
182
+ previewFlushTimer = undefined;
183
+ }
184
+ flushImagePreviewEntry(pi, stagedImagePreview);
185
+ stagedImagePreview = undefined;
186
+ }
121
187
  // grep/bash tree panels are NOT cleared here: historical panels must keep
122
188
  // their state so Pi re-renders of previous messages (scroll/resume) stay
123
189
  // 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
+ }