Files
oh-my-pi/packages/coding-agent/src/modes/image-references.ts
T
can1357 d0d5b547ff feat(editor): added atomic deletion and metadata display for image and paste markers
- Added atomicTokenPattern support to treat image and paste markers as indivisible tokens, enabling atomic deletion on backspace and forward-delete instead of character-by-character removal.
- Extended image marker format to include pixel dimensions [Image #N, WxH] and updated paste marker format to [Paste #N, +X lines] or [Paste #N, Y chars] with consistent comma-separated metadata.
- Implemented accent styling for paste markers in editor rendering while preserving hyperlinks for image markers, improving visual distinction between marker types.
- Refactored image reference handling to unified placeholder system supporting both image and paste markers with kind-aware rendering callbacks and regex patterns.
2026-06-08 13:34:22 +02:00

118 lines
3.9 KiB
TypeScript

import type { ImageContent } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store";
import { fileHyperlink } from "../tui/hyperlink";
/** Matches `[Image #N]`/`[Image #N, WxH]` and `[Paste #N, +X lines]`/`[Paste #N, Y chars]` tokens.
* Group 1 is the kind (`Image`/`Paste`), group 2 the 1-based index. The optional metadata
* tail (`, …`) is captured loosely (no `]`/newline) so future label tweaks keep matching. */
export const PLACEHOLDER_REGEX = /\[(Image|Paste) #([1-9]\d*)(?:,[^\]\n]*)?\]/g;
type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise<BlobPutResult>;
type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult;
export type PlaceholderKind = "image" | "paste";
export interface PlaceholderRenderers {
renderText: (text: string) => string;
renderReference: (label: string, kind: PlaceholderKind, index: number) => string;
}
export function renderPlaceholders(text: string, renderers: PlaceholderRenderers): string {
PLACEHOLDER_REGEX.lastIndex = 0;
let result = "";
let last = 0;
let matched = false;
for (;;) {
const match = PLACEHOLDER_REGEX.exec(text);
if (match === null) break;
matched = true;
if (match.index > last) {
result += renderers.renderText(text.slice(last, match.index));
}
const kind: PlaceholderKind = match[1] === "Paste" ? "paste" : "image";
result += renderers.renderReference(match[0], kind, Number(match[2]));
last = match.index + match[0].length;
}
if (!matched) {
return renderers.renderText(text);
}
if (last < text.length) {
result += renderers.renderText(text.slice(last));
}
return result;
}
export function imageReferenceHyperlink(
label: string,
index: number,
imageLinks: readonly (string | undefined)[] | undefined,
renderLabel: (text: string) => string,
): string {
const rendered = renderLabel(label);
const target = imageLinks?.[index - 1];
return target ? fileHyperlink(target, rendered) : rendered;
}
async function materializeImageReferenceLinkAsync(
image: ImageContent,
index: number,
putBlob: ImageBlobWriter,
): Promise<string | undefined> {
try {
const result = await putBlob(Buffer.from(image.data, "base64"), {
extension: blobExtensionForImageMimeType(image.mimeType),
});
return result.displayPath;
} catch (error) {
logger.warn("Failed to write image reference blob", {
index,
mimeType: image.mimeType,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
function materializeImageReferenceLink(
image: ImageContent,
index: number,
putBlob: ImageBlobWriterSync,
): string | undefined {
try {
const result = putBlob(Buffer.from(image.data, "base64"), {
extension: blobExtensionForImageMimeType(image.mimeType),
});
return result.displayPath;
} catch (error) {
logger.warn("Failed to write image reference blob", {
index,
mimeType: image.mimeType,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
export async function materializeImageReferenceLinks(
images: readonly ImageContent[] | undefined,
putBlob: ImageBlobWriter,
): Promise<(string | undefined)[] | undefined> {
if (!images || images.length === 0) return undefined;
const links = await Promise.all(
images.map((image, index) => materializeImageReferenceLinkAsync(image, index + 1, putBlob)),
);
return links.some(link => link !== undefined) ? links : undefined;
}
export function materializeImageReferenceLinksSync(
images: readonly ImageContent[] | undefined,
putBlob: ImageBlobWriterSync,
): (string | undefined)[] | undefined {
if (!images || images.length === 0) return undefined;
const links = images.map((image, index) => materializeImageReferenceLink(image, index + 1, putBlob));
return links.some(link => link !== undefined) ? links : undefined;
}