@quandev104/pi-style 0.2.3 → 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.
|
@@ -21,15 +21,19 @@
|
|
|
21
21
|
// multiple images render side-by-side on kitty-capable terminals (kitty
|
|
22
22
|
// graphics sequences are zero-width, so line zipping composes them), stacked
|
|
23
23
|
// elsewhere. Width is capped by `messages.previewMaxWidth` (default 30);
|
|
24
|
-
//
|
|
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.
|
|
25
27
|
//
|
|
26
28
|
// Fail-closed rules: unknown/malformed entry data renders zero lines (never
|
|
27
29
|
// an error box, never base64 leakage); a theme without fg disables the
|
|
28
30
|
// surface; the config leaf gates both the stage and the render side.
|
|
29
31
|
|
|
30
32
|
import {
|
|
33
|
+
type CellDimensions,
|
|
31
34
|
type Component,
|
|
32
35
|
getCapabilities,
|
|
36
|
+
getCellDimensions,
|
|
33
37
|
getImageDimensions,
|
|
34
38
|
Image,
|
|
35
39
|
type ImageDimensions,
|
|
@@ -50,6 +54,8 @@ export const IMAGE_PREVIEW_MAX_WIDTH_CELLS = 60;
|
|
|
50
54
|
/** Grid layout constants (kitty side-by-side). */
|
|
51
55
|
const GRID_GAP = 2;
|
|
52
56
|
const GRID_MIN_COLUMN = 14;
|
|
57
|
+
const GRID_MAX_HEIGHT_RATIO = 1.5;
|
|
58
|
+
const FALLBACK_IMAGE_DIMENSIONS: ImageDimensions = { widthPx: 800, heightPx: 600 };
|
|
53
59
|
|
|
54
60
|
/** Persisted entry payload: base64 data + mime type per attached image. */
|
|
55
61
|
export interface ImagePreviewImage {
|
|
@@ -132,6 +138,7 @@ function parseImageDimensions(image: ImagePreviewImage): ImageDimensions | undef
|
|
|
132
138
|
/** Per-entry render artifacts (components, labels). */
|
|
133
139
|
interface PreviewCache {
|
|
134
140
|
readonly images: readonly ImagePreviewImage[];
|
|
141
|
+
readonly dimensions: readonly (ImageDimensions | undefined)[];
|
|
135
142
|
readonly components: Image[];
|
|
136
143
|
readonly labels: string[];
|
|
137
144
|
}
|
|
@@ -172,10 +179,10 @@ export function renderImagePreviewEntry(entry: unknown, options: unknown, theme:
|
|
|
172
179
|
),
|
|
173
180
|
);
|
|
174
181
|
const labels = images.map((_image, index) => imageLabel(themed, index + 1, dims[index]));
|
|
175
|
-
cached = { images, components, labels };
|
|
182
|
+
cached = { images, dimensions: dims, components, labels };
|
|
176
183
|
previewCache.set(entry as object, cached);
|
|
177
184
|
}
|
|
178
|
-
const { components, labels } = cached;
|
|
185
|
+
const { components, dimensions, labels } = cached;
|
|
179
186
|
// Memoized output: render passes tick on every streaming update; the
|
|
180
187
|
// same width/expansion/config/capability key returns the SAME array
|
|
181
188
|
// reference, skipping the grid re-zip and the host's per-line diff.
|
|
@@ -196,7 +203,7 @@ export function renderImagePreviewEntry(entry: unknown, options: unknown, theme:
|
|
|
196
203
|
let lines: string[];
|
|
197
204
|
if (width <= 0) lines = [];
|
|
198
205
|
else if (images.length === 1 || !kitty) lines = renderStacked(width, maxWidth, labels, components);
|
|
199
|
-
else lines = renderGrid(width, maxWidth, labels, components);
|
|
206
|
+
else lines = renderGrid(width, maxWidth, labels, components, dimensions);
|
|
200
207
|
memo = { key, lines };
|
|
201
208
|
return lines;
|
|
202
209
|
},
|
|
@@ -230,9 +237,17 @@ function stripTrailingSpaces(line: string): string {
|
|
|
230
237
|
/**
|
|
231
238
|
* Side-by-side grid (kitty graphics only — sequences are zero-width so
|
|
232
239
|
* per-row line zipping composes columns). Columns shrink to fit the width;
|
|
233
|
-
*
|
|
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.
|
|
234
243
|
*/
|
|
235
|
-
function renderGrid(
|
|
244
|
+
function renderGrid(
|
|
245
|
+
width: number,
|
|
246
|
+
maxWidth: number,
|
|
247
|
+
labels: string[],
|
|
248
|
+
components: Image[],
|
|
249
|
+
dimensions: readonly (ImageDimensions | undefined)[],
|
|
250
|
+
): string[] {
|
|
236
251
|
const usable = Math.max(1, width - 2);
|
|
237
252
|
const maxColumns = Math.floor((usable + GRID_GAP) / (GRID_MIN_COLUMN + GRID_GAP));
|
|
238
253
|
const columns = Math.max(1, Math.min(components.length, maxColumns, 3));
|
|
@@ -241,36 +256,48 @@ function renderGrid(width: number, maxWidth: number, labels: string[], component
|
|
|
241
256
|
if (columnWidth < GRID_MIN_COLUMN) return renderStacked(width, maxWidth, labels, components);
|
|
242
257
|
|
|
243
258
|
const lines: string[] = [];
|
|
259
|
+
const cellDimensions = getCellDimensions();
|
|
244
260
|
for (let start = 0; start < components.length; start += columns) {
|
|
245
261
|
const group = components.slice(start, start + columns);
|
|
246
262
|
const groupLabels = labels.slice(start, start + columns);
|
|
263
|
+
const groupDimensions = dimensions.slice(start, start + columns);
|
|
247
264
|
if (start > 0) lines.push("");
|
|
248
265
|
const rendered = group.map((component) => component.render(columnWidth));
|
|
249
|
-
|
|
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
|
+
);
|
|
250
282
|
const labelRow = groupLabels
|
|
251
|
-
.map((label,
|
|
252
|
-
const pad = Math.max(0,
|
|
283
|
+
.map((label, index) => {
|
|
284
|
+
const pad = Math.max(0, (slotWidths[index] ?? 0) - visibleWidth(stripFormatting(label)));
|
|
253
285
|
return label + " ".repeat(pad);
|
|
254
286
|
})
|
|
255
287
|
.join(" ".repeat(GRID_GAP))
|
|
256
288
|
.trimEnd();
|
|
257
289
|
lines.push(labelRow);
|
|
258
|
-
|
|
259
|
-
//
|
|
260
|
-
//
|
|
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.
|
|
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.
|
|
265
293
|
const rows = Math.max(...rendered.map((column) => column.length));
|
|
266
294
|
for (let row = 0; row < rows; row++) {
|
|
267
295
|
let line = "";
|
|
296
|
+
let startCol = 0;
|
|
268
297
|
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
|
-
}
|
|
298
|
+
if (column > 0) line += `\x1b[${startCol + 1}G`;
|
|
273
299
|
line += rendered[column]?.[row] ?? "";
|
|
300
|
+
startCol += (slotWidths[column] ?? 0) + GRID_GAP;
|
|
274
301
|
}
|
|
275
302
|
lines.push(stripTrailingSpaces(line));
|
|
276
303
|
}
|
|
@@ -278,6 +305,26 @@ function renderGrid(width: number, maxWidth: number, labels: string[], component
|
|
|
278
305
|
return lines;
|
|
279
306
|
}
|
|
280
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
|
+
|
|
281
328
|
/** Label text may carry ANSI color; measure only the visible part.
|
|
282
329
|
* Reuses the shared ANSI-stripping utility (no local control-char regex). */
|
|
283
330
|
import { stripAnsi as stripFormatting } from "../../shared/ansi.js";
|
|
@@ -76,21 +76,20 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
76
76
|
// entries whose customType has no renderer — and once at extension load is
|
|
77
77
|
// enough (persisted entries from previous sessions render on resume).
|
|
78
78
|
registerImagePreviewSurface(pi);
|
|
79
|
-
// User-prompt image previews (ADR 0008):
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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.
|
|
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).
|
|
90
87
|
let stagedImagePreview: ImagePreviewEntryData | undefined;
|
|
88
|
+
let previewFlushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
91
89
|
pi.on("before_agent_start", (event) => {
|
|
92
|
-
|
|
93
|
-
|
|
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 ?? []);
|
|
94
93
|
});
|
|
95
94
|
pi.on("session_start", async (event, ctx) => {
|
|
96
95
|
resetUsageFromSessionCache(ctx.sessionManager);
|
|
@@ -163,9 +162,25 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
163
162
|
// A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
|
|
164
163
|
// new message start a fresh batch instead of joining the previous one.
|
|
165
164
|
closeActiveBatch();
|
|
166
|
-
|
|
167
|
-
|
|
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.
|
|
168
179
|
if (stagedImagePreview && event.message?.role === "assistant") {
|
|
180
|
+
if (previewFlushTimer) {
|
|
181
|
+
clearTimeout(previewFlushTimer);
|
|
182
|
+
previewFlushTimer = undefined;
|
|
183
|
+
}
|
|
169
184
|
flushImagePreviewEntry(pi, stagedImagePreview);
|
|
170
185
|
stagedImagePreview = undefined;
|
|
171
186
|
}
|