@quandev104/pi-style 0.2.1 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +8 -3
  3. package/dist/extensions/pi-style.js +1328 -333
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +8 -0
  6. package/extension-src/pi-style/app/runtime.ts +33 -4
  7. package/extension-src/pi-style/domain/config-normalization.ts +11 -1
  8. package/extension-src/pi-style/domain/config-types.ts +12 -0
  9. package/extension-src/pi-style/domain/status-renderer.ts +41 -9
  10. package/extension-src/pi-style/domain/status.ts +28 -12
  11. package/extension-src/pi-style/domain/theme.ts +32 -1
  12. package/extension-src/pi-style/features/editor/index.ts +144 -5
  13. package/extension-src/pi-style/features/messages/image-input.ts +205 -0
  14. package/extension-src/pi-style/features/messages/image-preview.ts +288 -0
  15. package/extension-src/pi-style/features/messages/index.ts +302 -61
  16. package/extension-src/pi-style/features/messages/render-config.ts +36 -0
  17. package/extension-src/pi-style/features/status-line/index.ts +41 -11
  18. package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
  19. package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
  20. package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
  21. package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
  22. package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
  23. package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
  24. package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
  25. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
  26. package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
  27. package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
  28. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
  29. package/extension-src/pi-style/pi/index.ts +55 -2
  30. package/extension-src/pi-style/pi/session-coordinator.ts +13 -0
  31. package/extension-src/pi-style/shared/ansi.ts +17 -5
  32. package/extension-src/pi-style/shared/box.ts +70 -4
  33. package/extension-src/pi-style/shared/clipboard-path.ts +98 -0
  34. package/extension-src/pi-style/shared/clipboard-presence.ts +112 -0
  35. package/extension-src/pi-style/shared/pending-images.ts +199 -0
  36. package/extension-src/pi-style/shared/split-diff.ts +8 -5
  37. package/package.json +1 -1
  38. package/extension-src/pi-style/features/.gitkeep +0 -0
@@ -0,0 +1,205 @@
1
+ // Clipboard image input (ADR 0009): submit-side wiring.
2
+ //
3
+ // The editor surface (StyledEditor) owns the keystroke-time behavior:
4
+ // instant `[Image #N] ` markers via the shared pending registry. This module
5
+ // owns what happens at submit: resolve markers against the registry
6
+ // (one-shot; removed markers discard their images), then upgrade any raw
7
+ // clipboard artifact path tokens that bypassed the editor (native editor
8
+ // style, config toggled after paste, non-pi-style surfaces) to `[image]` +
9
+ // attachments. Fail-safe at every step: missing/oversized files keep their
10
+ // text, nothing is lost. Filesystem reads happen on the input path only —
11
+ // renderers stay I/O-free (TOOL-007).
12
+
13
+ import { readFile, stat } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { clipboardPathRegex, extractClipboardImageTokens as sharedExtractTokens } from "../../shared/clipboard-path.js";
16
+ import { clipboardHasImageSync, readClipboardImageBinary } from "../../shared/clipboard-presence.js";
17
+ import {
18
+ allocatePendingImage,
19
+ discardPendingMarkerIndex,
20
+ fillPendingImageBytes,
21
+ fillPendingImageFromPath,
22
+ isPendingImageFilled,
23
+ isPendingMarkerIndex,
24
+ type PendingImageAttachment,
25
+ resolvePendingImagesForSubmit as sharedResolve,
26
+ } from "../../shared/pending-images.js";
27
+ import { getMessagesRenderConfig } from "./render-config.js";
28
+
29
+ /** Replacement token for an attached clipboard image (marker vocabulary). */
30
+ export const IMAGE_TOKEN = "[image]";
31
+
32
+ /** Mirrors image-paste's guard; larger files keep the path. */
33
+ const MAX_ATTACHABLE_BYTES = 20 * 1024 * 1024;
34
+
35
+ const MIME_BY_EXTENSION: Readonly<Record<string, string>> = Object.freeze({
36
+ png: "image/png",
37
+ jpg: "image/jpeg",
38
+ jpeg: "image/jpeg",
39
+ webp: "image/webp",
40
+ gif: "image/gif",
41
+ });
42
+
43
+ export { extractClipboardImageTokens, isSingleClipboardImagePath } from "../../shared/clipboard-path.js";
44
+ export {
45
+ type PendingImageAttachment,
46
+ resetPendingImageRegistry,
47
+ resolvePendingImagesForSubmit,
48
+ } from "../../shared/pending-images.js";
49
+
50
+ // ── Clipboard image paste surface (editor wiring; ADR 0009) ─────────────
51
+
52
+ /** Structural surface the editor consumes (kept structural so the editor
53
+ * feature needs no cross-feature import; app/runtime passes the instance). */
54
+ export interface ClipboardImagePasteSurface {
55
+ /** Config gate (`messages.clipboardImages`). */
56
+ enabled(): boolean;
57
+ /** Sync clipboard image presence; null when the probe is unavailable. */
58
+ clipboardHasImage(): boolean | null;
59
+ /** Instant marker: allocate + insert-time text; the bytes fill
60
+ * asynchronously from the clipboard. */
61
+ markerFromClipboard(): string;
62
+ /** Marker (or the original path on failure) from a clipboard artifact
63
+ * file — the fallback when the keystroke wasn't owned. */
64
+ markerFromArtifact(path: string): Promise<string>;
65
+ isMarkerIndexRegistered(index: number): boolean;
66
+ discardMarkerIndex(index: number): void;
67
+ }
68
+
69
+ /** Test seam for the native clipboard probes. */
70
+ export interface ClipboardImagePasteDeps {
71
+ hasImage?: () => boolean | null;
72
+ readBinary?: () => Promise<{ bytes: Uint8Array } | null>;
73
+ }
74
+
75
+ export function createClipboardImagePasteSurface(deps: ClipboardImagePasteDeps = {}): ClipboardImagePasteSurface {
76
+ const hasImage = deps.hasImage ?? clipboardHasImageSync;
77
+ // Owned-paste path: markerFromClipboard runs only after clipboardHasImage()
78
+ // returned true (editor keystroke handler), so the default binary read
79
+ // skips the redundant second native presence probe.
80
+ const readBinary = deps.readBinary ?? (() => readClipboardImageBinary({ skipPresenceProbe: true }));
81
+ return {
82
+ enabled: () => getMessagesRenderConfig().clipboardImages,
83
+ clipboardHasImage: () => {
84
+ try {
85
+ return hasImage();
86
+ } catch {
87
+ return null;
88
+ }
89
+ },
90
+ markerFromClipboard() {
91
+ const { index, marker } = allocatePendingImage();
92
+ // Async fill: bytes land in the registry entry before submit resolves.
93
+ void (async () => {
94
+ try {
95
+ const image = await readBinary();
96
+ if (image) {
97
+ // Zero-copy view (offset+length explicit so Uint8Array views over a
98
+ // larger backing buffer encode exactly their own range — avoids a
99
+ // second full copy of a ~1.3MB screenshot per paste).
100
+ fillPendingImageBytes(
101
+ index,
102
+ Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength).toString("base64"),
103
+ "image/png",
104
+ );
105
+ return;
106
+ }
107
+ } catch {
108
+ // Read failure below discards the entry.
109
+ }
110
+ // No bytes: discard so a submit never dangles on this entry (the
111
+ // marker stays plain text — documented degradation, nothing lost).
112
+ discardPendingMarkerIndex(index);
113
+ })();
114
+ return marker;
115
+ },
116
+ async markerFromArtifact(path: string) {
117
+ const { index, marker } = allocatePendingImage();
118
+ await fillPendingImageFromPath(index, path);
119
+ return isPendingImageFilled(index) ? marker : path;
120
+ },
121
+ isMarkerIndexRegistered: (index) => isPendingMarkerIndex(index),
122
+ discardMarkerIndex: (index) => discardPendingMarkerIndex(index),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Resolve `[Image #N]` markers for a submit (delegates to the shared
128
+ * registry; one-shot per submit). Config-gated.
129
+ */
130
+ export async function resolvePendingImageMarkers(
131
+ text: string,
132
+ ): Promise<{ images: PendingImageAttachment[] } | undefined> {
133
+ if (!getMessagesRenderConfig().clipboardImages) return undefined;
134
+ return sharedResolve(text);
135
+ }
136
+
137
+ // ── Submit transform (raw artifact tokens) ─────────────────────────────────
138
+
139
+ /**
140
+ * Input transform for raw clipboard-paste path tokens that bypassed the
141
+ * editor interception: read each token, attach the bytes as ImageContent,
142
+ * rewrite the token to `[image]`. Tokens whose file is missing/unreadable or
143
+ * over the size guard keep their original text. Returns undefined when
144
+ * nothing changed (caller keeps `action: "continue"`).
145
+ */
146
+ export async function transformClipboardImages(
147
+ text: string,
148
+ deps: {
149
+ readFile?: (path: string) => Promise<Uint8Array>;
150
+ statSize?: (path: string) => Promise<number | undefined>;
151
+ tmpRoot?: string;
152
+ } = {},
153
+ ): Promise<{ text: string; images: PendingImageAttachment[] } | undefined> {
154
+ if (!getMessagesRenderConfig().clipboardImages) return undefined;
155
+ const tokens = sharedExtractTokens(text, deps.tmpRoot);
156
+ if (tokens.length === 0) return undefined;
157
+
158
+ const attached: PendingImageAttachment[] = [];
159
+ const rewritten = new Map<string, string>();
160
+ for (const token of tokens) {
161
+ const image = await readAttachable(token, deps);
162
+ if (!image) continue;
163
+ attached.push({ type: "image", data: image.data, mimeType: image.mimeType });
164
+ rewritten.set(token, IMAGE_TOKEN);
165
+ }
166
+ if (attached.length === 0) return undefined;
167
+ return { text: rewriteTokens(text, rewritten, deps.tmpRoot), images: attached };
168
+ }
169
+
170
+ async function readAttachable(
171
+ path: string,
172
+ deps:
173
+ | { readFile?: (path: string) => Promise<Uint8Array>; statSize?: (path: string) => Promise<number | undefined> }
174
+ | undefined,
175
+ ): Promise<{ data: string; mimeType: string } | undefined> {
176
+ const extension = path.split(".").pop() ?? "";
177
+ const mimeType = MIME_BY_EXTENSION[extension];
178
+ if (!mimeType) return undefined;
179
+ const readFileImpl = deps?.readFile ?? ((p: string) => readFile(p));
180
+ const statSizeImpl =
181
+ deps?.statSize ??
182
+ (async (p: string) => {
183
+ try {
184
+ return (await stat(p)).size;
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ });
189
+ try {
190
+ const size = await statSizeImpl(path);
191
+ if (size === undefined || size <= 0 || size > MAX_ATTACHABLE_BYTES) return undefined;
192
+ const bytes = await readFileImpl(path);
193
+ if (bytes.length === 0 || bytes.length > MAX_ATTACHABLE_BYTES) return undefined;
194
+ return { data: Buffer.from(bytes).toString("base64"), mimeType };
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ }
199
+
200
+ function rewriteTokens(text: string, rewritten: Map<string, string>, tmpRoot?: string): string {
201
+ if (rewritten.size === 0) return text;
202
+ return text.replace(clipboardPathRegex(tmpRoot ?? tmpdir()), (match, token: string) =>
203
+ rewritten.has(token) ? IMAGE_TOKEN : match,
204
+ );
205
+ }
@@ -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
+ }