@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
- // Pi's global expansion (Ctrl+O) lifts the cap for a closer look.
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
- * when fewer than two usable columns fit, this degrades to stacked.
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(width: number, maxWidth: number, labels: string[], components: Image[]): string[] {
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
- // Label row: each label padded to its column width, gap between.
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, _index) => {
252
- const pad = Math.max(0, columnWidth - visibleWidth(stripFormatting(label)));
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
- // 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.
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): 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.
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
- const staged = stageImagePreviewData(event.images ?? []);
93
- if (staged) stagedImagePreview = staged;
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
- // Flush the staged image preview below the just-rendered user message
167
- // (ADR 0008 ordering see the before_agent_start comment above).
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",