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 d63c248fb83e..247305de4e62 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -2,6 +2,8 @@ import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" +import { pathToFileURL } from "bun" +import { existsSync } from "fs" import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" @@ -58,6 +60,40 @@ export type PromptRef = { const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"] +// Tokenize shell-style strings: handles backslash-escaped spaces, +// double-quoted and single-quoted segments. Covers all WezTerm +// quote_dropped_files modes (SpacesOnly, Posix, Windows). +function shellTokens(s: string): string[] { + const tokens: string[] = [] + let cur = "" + let i = 0 + while (i < s.length) { + if (s[i] === "\\" && i + 1 < s.length) { + cur += s[i + 1] + i += 2 + } else if (s[i] === '"') { + i++ + while (i < s.length && s[i] !== '"') { + if (s[i] === "\\" && i + 1 < s.length) { cur += s[i + 1]; i += 2 } + else { cur += s[i]; i++ } + } + i++ + } else if (s[i] === "'") { + i++ + while (i < s.length && s[i] !== "'") { cur += s[i]; i++ } + i++ + } else if (s[i] === " " || s[i] === "\t") { + if (cur) { tokens.push(cur); cur = "" } + i++ + } else { + cur += s[i] + i++ + } + } + if (cur) tokens.push(cur) + return tokens +} + export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -78,6 +114,7 @@ export function Prompt(props: PromptProps) { const { theme, syntax } = useTheme() const kv = useKV() + function promptModelWarning() { toast.show({ variant: "warning", @@ -736,6 +773,61 @@ export function Prompt(props: PromptProps) { return } + async function pasteFile(filepath: string): Promise { + // 1. Normalize path + let fp = filepath + if (fp.startsWith("file://")) fp = decodeURIComponent(new URL(fp).pathname) + if (fp.startsWith("~/")) fp = path.join(process.env["HOME"] ?? "~", fp.slice(2)) + if (!path.isAbsolute(fp)) fp = path.resolve(sync.data.path.directory || process.cwd(), fp) + + // 2. Validate — return silently if not found + if (!(await Filesystem.exists(fp))) return + + // 3. Get metadata + const isDir = await Filesystem.isDir(fp) + const name = path.basename(fp) + const mime = isDir ? "inode/directory" : Filesystem.mimeType(fp) + const virtualText = isDir ? `[Dir: ${name}/]` : `[File: ${name}]` + + // 4. Create extmark (same pattern as pasteImage) + const currentOffset = input.visualCursor.offset + const extmarkStart = currentOffset + const extmarkEnd = extmarkStart + virtualText.length + + input.insertText(virtualText + " ") + + const extmarkId = input.extmarks.create({ + start: extmarkStart, + end: extmarkEnd, + virtual: true, + styleId: pasteStyleId, + typeId: promptPartTypeId, + }) + + // 5. Create FilePart with file:// URL (NOT data: URL) + const url = pathToFileURL(fp).href + const relPath = path.relative(sync.data.path.directory || process.cwd(), fp) + + const part: Omit = { + type: "file" as const, + mime, + filename: name, + url, + source: { + type: "file", + path: relPath, + 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) + }), + ) + } + const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary @@ -912,6 +1004,7 @@ export function Prompt(props: PromptProps) { }} onSubmit={submit} onPaste={async (event: PasteEvent) => { + if (props.disabled) { event.preventDefault() return @@ -930,6 +1023,106 @@ export function Prompt(props: PromptProps) { // trim ' from the beginning and end of the pasted content. just // ' and nothing else const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + const lines = pastedContent + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + if (lines.length > 1) { + const fps = lines.map((l) => l.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")) + const resolved = fps.map((fp) => { + if (fp.startsWith("file://")) return decodeURIComponent(new URL(fp).pathname) + if (fp.startsWith("~/")) return path.join(process.env["HOME"] ?? "~", fp.slice(2)) + if (!path.isAbsolute(fp)) return path.resolve(sync.data.path.directory || process.cwd(), fp) + return fp + }) + if (fps.every((fp) => fp.startsWith("/") || fp.startsWith("~/") || fp.startsWith("file://"))) { + event.preventDefault() + const allExist = await Promise.all(resolved.map((fp) => Filesystem.exists(fp))) + if (allExist.every(Boolean)) { + for (const fp of resolved) { + const mime = Filesystem.mimeType(fp) + if (mime.startsWith("image/") && mime !== "image/svg+xml") { + const filename = path.basename(fp) + const content = await Filesystem.readArrayBuffer(fp) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => undefined) + if (content) await pasteImage({ filename, mime, content }) + } else { + await pasteFile(fp) + } + } + return + } + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ( + (lineCount >= 3 || pastedContent.length > 150) && + !sync.data.config.experimental?.disable_paste_summary + ) { + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + } else { + input.insertText(pastedContent) + } + return + } + } + + // WezTerm DnD: space-separated paths with backslash-escaped spaces + // (quote_dropped_files = SpacesOnly/Posix/Windows modes) + if (lines.length === 1) { + const tokens = shellTokens(pastedContent) + if (tokens.length > 1 && tokens.every((t) => t.startsWith("/") || t.startsWith("~/") || t.startsWith("file://"))) { + event.preventDefault() + const resolved = tokens.map((t) => { + if (t.startsWith("file://")) return decodeURIComponent(new URL(t).pathname) + if (t.startsWith("~/")) return path.join(process.env["HOME"] ?? "~", t.slice(2)) + if (!path.isAbsolute(t)) return path.resolve(sync.data.path.directory || process.cwd(), t) + return t + }) + const allExist = await Promise.all(resolved.map((fp) => Filesystem.exists(fp))) + if (allExist.every(Boolean)) { + for (const fp of resolved) { + const mime = Filesystem.mimeType(fp) + if (mime.startsWith("image/") && mime !== "image/svg+xml") { + const filename = path.basename(fp) + const content = await Filesystem.readArrayBuffer(fp) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => undefined) + if (content) await pasteImage({ filename, mime, content }) + } else { + await pasteFile(fp) + } + } + return + } + input.insertText(pastedContent) + return + } + // Single shell token that's a valid path (WezTerm DnD of 1 file) + if (tokens.length === 1) { + const t = tokens[0] + if (t.startsWith("/") || t.startsWith("~/") || t.startsWith("file://")) { + const fp = t.startsWith("file://") + ? decodeURIComponent(new URL(t).pathname) + : t.startsWith("~/") + ? path.join(process.env["HOME"] ?? "~", t.slice(2)) + : t + if (existsSync(fp)) { + event.preventDefault() + const mime = Filesystem.mimeType(fp) + if (mime.startsWith("image/") && mime !== "image/svg+xml") { + const filename = path.basename(fp) + const content = await Filesystem.readArrayBuffer(fp) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => undefined) + if (content) await pasteImage({ filename, mime, content }) + } else { + await pasteFile(fp) + } + return + } + } + } + } const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { try { @@ -958,6 +1151,11 @@ export function Prompt(props: PromptProps) { return } } + if (existsSync(filepath)) { + event.preventDefault() + await pasteFile(filepath) + return + } } catch {} } diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 7d1aad3a86e8..f5d4835af08d 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -56,13 +56,39 @@ export namespace Clipboard { } if (os === "linux") { - const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer() - if (wayland && wayland.byteLength > 0) { - return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" } - } - const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer() - if (x11 && x11.byteLength > 0) { - return { data: Buffer.from(x11).toString("base64"), mime: "image/png" } + const mimePriority = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp"] + if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-paste")) { + const types = await $`wl-paste --list-types`.nothrow().quiet().text() + if (types) { + const available = types + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + for (const mime of mimePriority) { + if (available.includes(mime)) { + const data = await $`wl-paste -t ${mime}`.nothrow().quiet().arrayBuffer() + if (data && data.byteLength > 0) { + return { data: Buffer.from(data).toString("base64"), mime } + } + } + } + } + } else if (Bun.which("xclip")) { + const targets = await $`xclip -selection clipboard -t TARGETS -o`.nothrow().quiet().text() + if (targets) { + const available = targets + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + for (const mime of mimePriority) { + if (available.includes(mime)) { + const data = await $`xclip -selection clipboard -t ${mime} -o`.nothrow().quiet().arrayBuffer() + if (data && data.byteLength > 0) { + return { data: Buffer.from(data).toString("base64"), mime } + } + } + } + } } }