@quandev104/pi-style 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +8 -3
- package/dist/extensions/pi-style.js +674 -49
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +8 -0
- package/extension-src/pi-style/app/runtime.ts +4 -0
- package/extension-src/pi-style/domain/config-normalization.ts +11 -1
- package/extension-src/pi-style/domain/config-types.ts +12 -0
- package/extension-src/pi-style/domain/status-renderer.ts +1 -1
- package/extension-src/pi-style/domain/status.ts +13 -7
- package/extension-src/pi-style/features/editor/index.ts +127 -0
- package/extension-src/pi-style/features/messages/image-input.ts +205 -0
- package/extension-src/pi-style/features/messages/image-preview.ts +288 -0
- package/extension-src/pi-style/features/messages/render-config.ts +36 -0
- package/extension-src/pi-style/pi/index.ts +53 -2
- package/extension-src/pi-style/pi/session-coordinator.ts +13 -0
- package/extension-src/pi-style/shared/clipboard-path.ts +98 -0
- package/extension-src/pi-style/shared/clipboard-presence.ts +112 -0
- package/extension-src/pi-style/shared/pending-images.ts +199 -0
- package/package.json +1 -1
- package/extension-src/pi-style/features/.gitkeep +0 -0
|
@@ -0,0 +1,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
|
+
}
|