From 49e52e0ca58a26727f59f53d63d61aec367bed1e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 28 Oct 2025 19:51:57 -0400 Subject: [PATCH] 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. --- .../cli/cmd/tui/component/prompt/index.tsx | 63 +++++++++++++++++++ .../src/cli/cmd/tui/util/clipboard.ts | 16 ++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 89f9aa36a..b04a812fb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -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 = { + 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 ( <>