@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.
- package/CHANGELOG.md +11 -0
- package/README.md +8 -3
- package/dist/extensions/pi-style.js +715 -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 +335 -0
- package/extension-src/pi-style/features/messages/render-config.ts +36 -0
- package/extension-src/pi-style/pi/index.ts +68 -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,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
|
+
}
|