Skip to content
198 changes: 198 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -78,6 +114,7 @@ export function Prompt(props: PromptProps) {
const { theme, syntax } = useTheme()
const kv = useKV()


function promptModelWarning() {
toast.show({
variant: "warning",
Expand Down Expand Up @@ -736,6 +773,61 @@ export function Prompt(props: PromptProps) {
return
}

async function pasteFile(filepath: string): Promise<void> {
// 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<FilePart, "id" | "messageID" | "sessionID"> = {
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
Expand Down Expand Up @@ -912,6 +1004,7 @@ export function Prompt(props: PromptProps) {
}}
onSubmit={submit}
onPaste={async (event: PasteEvent) => {

if (props.disabled) {
event.preventDefault()
return
Expand All @@ -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 {
Expand Down Expand Up @@ -958,6 +1151,11 @@ export function Prompt(props: PromptProps) {
return
}
}
if (existsSync(filepath)) {
event.preventDefault()
await pasteFile(filepath)
return
}
} catch {}
}

Expand Down
40 changes: 33 additions & 7 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
}
}
}
}

Expand Down
Loading