@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.
@@ -49,6 +49,9 @@ const allowedPaths = new Set([
49
49
  "messages.specialBlocks",
50
50
  "messages.hideThinkingLabel",
51
51
  "messages.hideInterimText",
52
+ "messages.showImagePreviews",
53
+ "messages.clipboardImages",
54
+ "messages.previewMaxWidth",
52
55
  "tools.enabled",
53
56
  "tools.style",
54
57
  "tools.maxCollapsedLines",
@@ -82,6 +85,9 @@ function validatePathValue(path: string, value: unknown): boolean {
82
85
  "messages.specialBlocks",
83
86
  "messages.hideThinkingLabel",
84
87
  "messages.hideInterimText",
88
+ "messages.showImagePreviews",
89
+ "messages.clipboardImages",
90
+ "messages.previewMaxWidth",
85
91
  "tools.enabled",
86
92
  "tools.showElapsed",
87
93
  "tools.dimOutput",
@@ -105,6 +111,8 @@ function validatePathValue(path: string, value: unknown): boolean {
105
111
  return typeof value === "string";
106
112
  if (path === "tools.maxCollapsedLines" || path === "tools.maxExpandedLines")
107
113
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
114
+ if (path === "messages.previewMaxWidth")
115
+ return typeof value === "number" && Number.isFinite(value) && value >= 8 && value <= 60;
108
116
  if (path === "statusLine.bottomMargin" || path === "statusLine.contextBarWidth")
109
117
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
110
118
  if (path.endsWith(".colors") || path.endsWith(".glyphs"))
@@ -5,6 +5,7 @@ import type { GitCommandRunner } from "../domain/providers.js";
5
5
  import type { ContextSnapshot, StatusSnapshot } from "../domain/status.js";
6
6
  import { normalizeThinkingLevel } from "../domain/status.js";
7
7
  import { installEditor } from "../features/editor/index.js";
8
+ import { createClipboardImagePasteSurface } from "../features/messages/image-input.js";
8
9
  import {
9
10
  installStartup,
10
11
  type StartupHost,
@@ -198,6 +199,9 @@ export function createPiStyleRuntime(
198
199
  generation,
199
200
  initialSnapshot: currentSnapshot,
200
201
  isCurrent: () => !disposed,
202
+ // Clipboard image paste surface (ADR 0009): instant `[Image #N] `
203
+ // markers at keystroke time, artifact fallback, atomic backspace.
204
+ clipboardImagePaste: createClipboardImagePasteSurface(),
201
205
  });
202
206
  if (editor) {
203
207
  disposables.add(editor);
@@ -16,7 +16,7 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
16
16
  startup: Object.freeze({ mode: "compact", showResources: false, alwaysExpanded: false }),
17
17
  statusLine: Object.freeze({
18
18
  enabled: true,
19
- separator: "powerline-thin",
19
+ separator: "|",
20
20
  layout: Object.freeze({
21
21
  left: ["path", "git", "context_bar", "cost"],
22
22
  right: ["model_effort"],
@@ -34,6 +34,9 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
34
34
  specialBlocks: true,
35
35
  hideThinkingLabel: true,
36
36
  hideInterimText: true,
37
+ showImagePreviews: true,
38
+ clipboardImages: true,
39
+ previewMaxWidth: 30,
37
40
  }),
38
41
  tools: Object.freeze({
39
42
  enabled: true,
@@ -178,6 +181,9 @@ export function normalizeConfig(
178
181
  specialBlocks: bool(messages.specialBlocks, defaults.messages.specialBlocks),
179
182
  hideThinkingLabel: bool(messages.hideThinkingLabel, defaults.messages.hideThinkingLabel),
180
183
  hideInterimText: bool(messages.hideInterimText, defaults.messages.hideInterimText),
184
+ showImagePreviews: bool(messages.showImagePreviews, defaults.messages.showImagePreviews),
185
+ clipboardImages: bool(messages.clipboardImages, defaults.messages.clipboardImages),
186
+ previewMaxWidth: boundedInt(messages.previewMaxWidth, defaults.messages.previewMaxWidth, 8, 60),
181
187
  }),
182
188
  tools: Object.freeze({
183
189
  enabled: bool(tools.enabled, defaults.tools.enabled),
@@ -239,6 +245,8 @@ const BOOL_PATHS = new Set([
239
245
  "messages.specialBlocks",
240
246
  "messages.hideThinkingLabel",
241
247
  "messages.hideInterimText",
248
+ "messages.showImagePreviews",
249
+ "messages.clipboardImages",
242
250
  "tools.enabled",
243
251
  "tools.showElapsed",
244
252
  "tools.dimOutput",
@@ -289,6 +297,8 @@ function validLeaf(path: string, value: unknown): boolean {
289
297
  return typeof value === "string";
290
298
  if (path === "tools.maxCollapsedLines" || path === "tools.maxExpandedLines")
291
299
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
300
+ if (path === "messages.previewMaxWidth")
301
+ return typeof value === "number" && Number.isFinite(value) && value >= 8 && value <= 60;
292
302
  if (path === "statusLine.bottomMargin" || path === "statusLine.contextBarWidth")
293
303
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
294
304
  if (STRING_ARRAY_PATHS.has(path)) return Array.isArray(value) && value.every((item) => typeof item === "string");
@@ -38,6 +38,12 @@ export interface PiStyleConfig {
38
38
  specialBlocks?: boolean;
39
39
  hideThinkingLabel?: boolean;
40
40
  hideInterimText?: boolean;
41
+ /** Inline previews for user-prompt images (ADR 0008); gates append and render. */
42
+ showImagePreviews?: boolean;
43
+ /** Clipboard image input (ADR 0009): upgrade built-in paste temp paths to attachments. */
44
+ clipboardImages?: boolean;
45
+ /** Max cell width per user-prompt image preview (ADR 0008); default 30. */
46
+ previewMaxWidth?: number;
41
47
  };
42
48
  tools?: {
43
49
  enabled?: boolean;
@@ -97,6 +103,12 @@ export interface NormalizedPiStyleConfig {
97
103
  specialBlocks: boolean;
98
104
  hideThinkingLabel: boolean;
99
105
  hideInterimText: boolean;
106
+ /** Inline previews for user-prompt images (ADR 0008); gates append and render. */
107
+ showImagePreviews: boolean;
108
+ /** Clipboard image input (ADR 0009): upgrade built-in paste temp paths to attachments. */
109
+ clipboardImages: boolean;
110
+ /** Max cell width per user-prompt image preview (ADR 0008); default 30. */
111
+ previewMaxWidth: number;
100
112
  };
101
113
  readonly tools: {
102
114
  enabled: boolean;
@@ -56,7 +56,7 @@ export function renderStatus(
56
56
  options: StatusRendererOptions,
57
57
  ): StatusRenderResult {
58
58
  if (width <= 0) return { primary: "", lines: [], visibleSegments: [] };
59
- const separator = options.separator ?? "";
59
+ const separator = options.separator ?? "|";
60
60
  const padding = options.padding ?? " ";
61
61
  const normalized = uniqueLayout(layout);
62
62
  const context: SegmentContext = { snapshot, theme: options.theme, options: options.options ?? {}, width };
@@ -252,12 +252,21 @@ export function createBuiltinSegments(): ReadonlyMap<StatusSegmentId, StatusSegm
252
252
  const percent = contextPercent(snapshot.context ?? {});
253
253
  if (percent === undefined) return { visible: false, content: "" };
254
254
  const token = contextBarToken(percent);
255
- const window = snapshot.context?.windowTokens;
256
- const label = `ctx${window !== undefined ? ` (${formatTokens(window)})` : ""}:`;
257
255
  const width = (options.context_bar?.width as number | undefined) ?? CONTEXT_BAR_WIDTH;
256
+ // Pipe-delimited context block: `[█████░░░░░] | 47% used | 235K/1.0M`.
257
+ // Uses plain `|` (not the tall `│`) so the inline block stays visually short.
258
+ // No boundary pipes: the status renderer's segment separator already
259
+ // delimits the block, and extra pipes would double it up.
260
+ const pipe = theme.apply("separator", "|");
261
+ const bar = `${theme.apply("muted", "[")}${theme.apply(token, contextBar(percent, width))}${theme.apply("muted", "]")}`;
262
+ const parts = [bar, `${theme.apply(token, `${Math.round(percent)}%`)}${theme.apply("muted", " used")}`];
263
+ const current = snapshot.context?.currentTokens;
264
+ const window = snapshot.context?.windowTokens;
265
+ if (current !== undefined && window !== undefined)
266
+ parts.push(theme.apply("muted", `${formatTokens(current)}/${formatTokens(window)}`));
258
267
  return {
259
268
  visible: true,
260
- content: `${theme.apply("muted", label)} ${theme.apply(token, contextBar(percent, width))} ${theme.apply(token, `${Math.round(percent)}%`)}`,
269
+ content: parts.join(` ${pipe} `),
261
270
  compactContent: theme.apply(token, `${Math.round(percent)}%`),
262
271
  };
263
272
  }),
@@ -362,10 +371,7 @@ function contextBarToken(percent: number): SemanticToken {
362
371
  }
363
372
 
364
373
  function formatTokens(value: number): string {
365
- if (value >= 1_000_000) {
366
- const millions = value / 1_000_000;
367
- return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
368
- }
374
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
369
375
  if (value >= 1_000) {
370
376
  const thousands = value / 1_000;
371
377
  return `${Number.isInteger(thousands) ? thousands : thousands.toFixed(1)}K`;
@@ -6,6 +6,7 @@ import { contextPercent, type StatusSnapshot, type ThinkingLevel } from "../../d
6
6
  import type { ResolvedTheme } from "../../domain/theme.js";
7
7
  import { resolveTheme } from "../../domain/theme.js";
8
8
  import { stripAnsi, truncateAnsi } from "../../shared/ansi.js";
9
+ import { clipboardMarkerAtEnd, isSingleClipboardImagePath } from "../../shared/clipboard-path.js";
9
10
 
10
11
  export const EDITOR_DIAGNOSTIC_KEY = "pi-style.editor";
11
12
  type SetEditorComponent = NonNullable<ExtensionUIContext["setEditorComponent"]>;
@@ -30,6 +31,18 @@ export interface EditorInstallation {
30
31
  dispose(): void;
31
32
  }
32
33
 
34
+ /** Structural clipboard-image paste surface (ADR 0009) consumed by the
35
+ * editor. Structural so this feature needs no cross-feature import; the
36
+ * app layer passes the instance built in features/messages. */
37
+ interface ClipboardImagePasteSurface {
38
+ enabled(): boolean;
39
+ clipboardHasImage(): boolean | null;
40
+ markerFromClipboard(): string;
41
+ markerFromArtifact(path: string): Promise<string>;
42
+ isMarkerIndexRegistered(index: number): boolean;
43
+ discardMarkerIndex(index: number): void;
44
+ }
45
+
33
46
  interface EditorOptions {
34
47
  config: NormalizedPiStyleConfig;
35
48
  snapshot: StatusSnapshot;
@@ -37,6 +50,22 @@ interface EditorOptions {
37
50
  /** Full Pi theme for thinking-level border colors (optional; falls back to borderColor). */
38
51
  fullTheme?: { getThinkingBorderColor?: (level: ThinkingLevel) => (str: string) => string };
39
52
  onSnapshot: (snapshot: StatusSnapshot) => void;
53
+ /** Clipboard image paste surface (ADR 0009): instant `[Image #N] ` markers
54
+ * at keystroke time, artifact-path fallback, atomic marker backspace.
55
+ * Undefined keeps native behavior entirely. */
56
+ clipboardImagePaste?: ClipboardImagePasteSurface;
57
+ }
58
+
59
+ /** Pi-tui Editor internals mirrored for O(1) atomic marker surgery. All are
60
+ * runtime-present on the Editor prototype chain but TS-private; the typed
61
+ * cast follows the autocompleteState-access pattern used in render(). */
62
+ interface EditorInternals {
63
+ readonly state: { lines: string[]; cursorLine: number; cursorCol: number };
64
+ lastAction: string | null;
65
+ readonly onChange?: (text: string) => void;
66
+ exitHistoryBrowsing(): void;
67
+ setCursorCol(col: number): void;
68
+ cancelAutocomplete(): void;
40
69
  }
41
70
 
42
71
  interface RenderPlan {
@@ -150,6 +179,10 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
150
179
  private semanticDirty = false;
151
180
  private disposed = false;
152
181
  private renderPlanCache: { key: string; plan: RenderPlan } | undefined;
182
+ private readonly clipboardImagePaste: ClipboardImagePasteSurface | undefined;
183
+ /** Keybinding matcher (the factory's KeybindingsManager) for keystroke-level
184
+ * interception: the paste keybinding and backspace checks below. */
185
+ private readonly editorKeybindings: { matches(data: string, keybinding: string): boolean };
153
186
 
154
187
  constructor(tui: Tui, theme: PiEditorTheme, keybindings: Keybindings, options: EditorOptions) {
155
188
  super(tui, theme, keybindings);
@@ -158,6 +191,8 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
158
191
  this.piTheme = theme;
159
192
  this.fullTheme = options.fullTheme;
160
193
  this.onSnapshot = options.onSnapshot;
194
+ this.clipboardImagePaste = options.clipboardImagePaste;
195
+ this.editorKeybindings = keybindings as unknown as { matches(data: string, keybinding: string): boolean };
161
196
  this.semantic = semanticTheme(theme, options.config);
162
197
  this.setPaddingX(0);
163
198
  }
@@ -177,10 +212,100 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
177
212
 
178
213
  override handleInput(data: string): void {
179
214
  if (this.disposed) return;
215
+ if (this.handleClipboardImagePasteKeystroke(data)) return;
216
+ if (this.handleAtomicMarkerBackspace(data)) return;
180
217
  super.handleInput(data);
181
218
  this.onSnapshot({ ...this.snapshot });
182
219
  }
183
220
 
221
+ /**
222
+ * Instant marker (ADR 0009): when the built-in paste keybinding fires, the
223
+ * clipboard holds an image (sync native probe), and the surface is enabled,
224
+ * the keystroke is owned here — an `[Image #N] ` marker is inserted
225
+ * immediately (zero-latency feedback) and the bytes fill the registry
226
+ * entry asynchronously. Pi's own paste handler never runs for this
227
+ * keystroke, so no temp artifact is written. Text pastes (no image) and
228
+ * probe-unavailable hosts fall through to the native path untouched.
229
+ */
230
+ private handleClipboardImagePasteKeystroke(data: string): boolean {
231
+ const surface = this.clipboardImagePaste;
232
+ if (!surface?.enabled()) return false;
233
+ if (!this.editorKeybindings.matches(data, "app.clipboard.pasteImage")) return false;
234
+ if (surface.clipboardHasImage() !== true) return false;
235
+ super.insertTextAtCursor(surface.markerFromClipboard());
236
+ this.onSnapshot({ ...this.snapshot });
237
+ return true;
238
+ }
239
+
240
+ /**
241
+ * Atomic marker backspace (ADR 0009): backspacing directly after a
242
+ * registered `[Image #N] ` marker deletes the whole marker as one unit —
243
+ * the image is discarded (image-paste semantics). Direct state surgery
244
+ * (O(1)) mirroring pi-tui's handleBackspace mutation protocol: no setText
245
+ * round-trip (it clears the pastes registry, and undo snapshots store that
246
+ * Map by reference — silently breaking `[paste #N]` atomicity on undo) and
247
+ * no per-column left-arrow inputs (O(K·L) through the keybinding chain and
248
+ * visual line maps). No undo snapshot is pushed: the discarded image
249
+ * cannot be restored, so the deletion is intentionally non-undoable.
250
+ */
251
+ private handleAtomicMarkerBackspace(data: string): boolean {
252
+ const surface = this.clipboardImagePaste;
253
+ if (!surface?.enabled()) return false;
254
+ const isBackspace = this.editorKeybindings.matches(data, "tui.editor.deleteCharBackward") || data === "\x7f";
255
+ if (!isBackspace) return false;
256
+ const lines = this.getLines();
257
+ const cursor = this.getCursor();
258
+ const current = lines[cursor.line];
259
+ if (current === undefined) return false;
260
+ const before = current.slice(0, cursor.col);
261
+ const hit = clipboardMarkerAtEnd(before);
262
+ if (!hit || !surface.isMarkerIndexRegistered(hit.index)) return false;
263
+ const editor = this as unknown as EditorInternals;
264
+ const targetCol = cursor.col - hit.length;
265
+ editor.exitHistoryBrowsing();
266
+ editor.lastAction = null;
267
+ editor.cancelAutocomplete();
268
+ // Splice the marker out of its line in place (deletion never removes a
269
+ // line, so cursorLine is unchanged). setCursorCol also clears
270
+ // preferredVisualCol/snappedFromCursorCol. scrollOffset is left alone;
271
+ // render() re-clamps it to keep the cursor visible.
272
+ editor.state.lines[cursor.line] = current.slice(0, targetCol) + current.slice(cursor.col);
273
+ editor.setCursorCol(targetCol);
274
+ editor.onChange?.(this.getText());
275
+ surface.discardMarkerIndex(hit.index);
276
+ this.invalidate();
277
+ this.onSnapshot({ ...this.snapshot });
278
+ return true;
279
+ }
280
+
281
+ /**
282
+ * Artifact-path fallback (ADR 0009): when the keystroke was not owned
283
+ * (probe unavailable, native editor path, config toggled) Pi's built-in
284
+ * paste still inserts a `<tmpdir>/pi-clipboard-<uuid>.<ext>` path through
285
+ * this method. Convert it to a marker when the artifact reads successfully;
286
+ * every failure inserts the original text verbatim (exact native behavior).
287
+ */
288
+ override insertTextAtCursor(text: string): void {
289
+ const surface = this.clipboardImagePaste;
290
+ if (!surface?.enabled() || !isSingleClipboardImagePath(text)) {
291
+ super.insertTextAtCursor(text);
292
+ return;
293
+ }
294
+ void (async () => {
295
+ let replacement = text;
296
+ try {
297
+ replacement = await surface.markerFromArtifact(text);
298
+ } catch {
299
+ // Unreadable artifact or surface failure: keep native behavior.
300
+ }
301
+ super.insertTextAtCursor(replacement);
302
+ // The caller (Pi's handleClipboardPaste) requested its render before
303
+ // this async insert completed — without a repaint the marker stays
304
+ // invisible until the next keystroke. Request one now.
305
+ this.tui.requestRender();
306
+ })();
307
+ }
308
+
184
309
  override invalidate(): void {
185
310
  super.invalidate();
186
311
  // The semantic theme is derived from (piTheme, config); piTheme is fixed per
@@ -412,6 +537,7 @@ export function installEditor(options: {
412
537
  generation: number;
413
538
  initialSnapshot: StatusSnapshot;
414
539
  isCurrent?: () => boolean;
540
+ clipboardImagePaste?: EditorOptions["clipboardImagePaste"];
415
541
  }): EditorInstallation | undefined {
416
542
  if (!options.host.setEditorComponent) return undefined;
417
543
  const previous = options.host.getEditorComponent?.();
@@ -436,6 +562,7 @@ export function installEditor(options: {
436
562
  snapshot,
437
563
  theme,
438
564
  ...(options.host.theme ? { fullTheme: options.host.theme } : {}),
565
+ ...(options.clipboardImagePaste ? { clipboardImagePaste: options.clipboardImagePaste } : {}),
439
566
  onSnapshot: (next) => {
440
567
  if (!disposed && options.isCurrent?.() !== false) snapshot = next;
441
568
  },
@@ -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
+ }