tui: add image paste support from clipboard

Users can now paste images directly from their clipboard into the prompt using the paste command. The image will be automatically converted to base64 and embedded as a file attachment. This works on macOS by reading PNG data from the clipboard and converting it to the appropriate format for the AI to process.
This commit is contained in:
Dax Raad 2025-10-28 19:51:57 -04:00
parent bcd637e8e5
commit 49e52e0ca5
2 changed files with 76 additions and 3 deletions

View file

@ -25,6 +25,8 @@ import { useCommandDialog } from "../dialog-command"
import { useRenderer } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk"
export type PromptProps = {
sessionID?: string
@ -139,6 +141,23 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
disabled: true,
keybind: "input_paste",
category: "Prompt",
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteImage({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
}
},
}
]
})
@ -384,6 +403,50 @@ export function Prompt(props: PromptProps) {
}
const exit = useExit()
async function pasteImage(file: {filename?: string, content: string, mime: string}) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
const count = store.prompt.parts.filter((x) => x.type === "file").length
const virtualText = `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
input.insertText(textToInsert)
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
type: "file" as const,
mime: file.mime,
filename: file.filename,
url: `data:${file.mime};base64,${file.content}`,
source: {
type: "file",
path: file.filename ?? "",
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
},
}
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push(part)
draft.extmarkToPartIndex.set(extmarkId, partIndex)
}),
)
return
}
return (
<>
<Autocomplete

View file

@ -2,6 +2,8 @@ import { $ } from "bun"
import { platform } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
export namespace Clipboard {
export interface Content {
@ -13,9 +15,17 @@ export namespace Clipboard {
const os = platform()
if (os === "darwin") {
const imageBuffer = await $`osascript -e 'try' -e 'the clipboard as «class PNGf»' -e 'end try'`.nothrow().text()
if (imageBuffer) {
return { data: Buffer.from(imageBuffer).toString("base64url"), mime: "image/png" }
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
const file = Bun.file(tmpfile)
const buffer = await file.arrayBuffer()
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
} catch {
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()
}
}