mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
* feat(tui): support pasting clipboard images * fix(tui): keep image placeholders atomic * fix(tui): reconcile duplicate image placeholders * fix(tui): retain highlighted image placeholders * fix(tui): preserve image placeholder layout * fix(tui): keep image display state local * fix(tui): reject images in commands
65 lines
2.2 KiB
TypeScript
65 lines
2.2 KiB
TypeScript
import {
|
|
createHostClipboard,
|
|
type HostClipboardService,
|
|
} from "@opentui/core"
|
|
|
|
const IMAGE_MIME_TYPES = [
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/webp",
|
|
"image/gif",
|
|
] as const
|
|
const MAX_IMAGE_BYTES = 6 * 1024 * 1024
|
|
|
|
export interface ClipboardImage {
|
|
dataUrl: string
|
|
mimeType: typeof IMAGE_MIME_TYPES[number]
|
|
}
|
|
|
|
export interface ClipboardImageReader {
|
|
read(): Promise<ClipboardImage>
|
|
dispose(): Promise<void>
|
|
}
|
|
|
|
type ClipboardFactory = () => HostClipboardService
|
|
|
|
function readFailure(status: string): Error {
|
|
if (status === "empty") return new Error("No image in clipboard")
|
|
if (status === "limit-exceeded") return new Error("Clipboard image is larger than 6 MB")
|
|
if (status === "timed-out") return new Error("Clipboard image read timed out")
|
|
return new Error("Clipboard image paste is unavailable")
|
|
}
|
|
|
|
/** Lazily owns OpenTUI's native host clipboard so ordinary TUI startup does no clipboard work. */
|
|
export function createClipboardImageReader(
|
|
createClipboard: ClipboardFactory = () => createHostClipboard({ maxReadBytes: MAX_IMAGE_BYTES }),
|
|
): ClipboardImageReader {
|
|
let clipboard: HostClipboardService | null = null
|
|
let disposed = false
|
|
|
|
return {
|
|
async read(): Promise<ClipboardImage> {
|
|
if (disposed) throw new Error("Clipboard image paste is unavailable")
|
|
clipboard ||= createClipboard()
|
|
const result = await clipboard.read({ preferredTypes: IMAGE_MIME_TYPES })
|
|
if (result.status !== "read") throw readFailure(result.status)
|
|
const normalizedMime = result.representation.mimeType.toLowerCase()
|
|
const mimeType = IMAGE_MIME_TYPES.find((candidate) => candidate === normalizedMime)
|
|
if (!mimeType) throw new Error("Clipboard does not contain a supported image")
|
|
const bytes = result.representation.bytes
|
|
if (!bytes.length) throw new Error("Clipboard image is empty")
|
|
if (bytes.length > MAX_IMAGE_BYTES) throw new Error("Clipboard image is larger than 6 MB")
|
|
return {
|
|
mimeType,
|
|
dataUrl: `data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`,
|
|
}
|
|
},
|
|
async dispose(): Promise<void> {
|
|
if (disposed) return
|
|
disposed = true
|
|
await clipboard?.dispose()
|
|
clipboard = null
|
|
},
|
|
}
|
|
}
|