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 d63c248fb83..bfaf4b545a4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -34,6 +34,8 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { decodeFileUriForDisplay, decodeFileUrisInText } from "@opencode-ai/util/path" +import { fileURLToPath } from "url" export type PromptProps = { sessionID?: string @@ -926,19 +928,28 @@ export function Prompt(props: PromptProps) { command.trigger("prompt.paste") return } + const decodedPastedContent = decodeFileUrisInText(pastedContent) // trim ' from the beginning and end of the pasted content. just // ' and nothing else - const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + const filepath = decodedPastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + const localPath = (() => { + if (!filepath.startsWith("file://")) return filepath + try { + return fileURLToPath(filepath) + } catch { + return decodeFileUriForDisplay(filepath).replace(/^file:\/\//, "") + } + })() const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { try { - const mime = Filesystem.mimeType(filepath) - const filename = path.basename(filepath) + const mime = Filesystem.mimeType(localPath) + const filename = path.basename(localPath) // Handle SVG as raw text content, not as base64 image if (mime === "image/svg+xml") { event.preventDefault() - const content = await Filesystem.readText(filepath).catch(() => {}) + const content = await Filesystem.readText(localPath).catch(() => {}) if (content) { pasteText(content, `[SVG: ${filename ?? "image"}]`) return @@ -946,7 +957,7 @@ export function Prompt(props: PromptProps) { } if (mime.startsWith("image/")) { event.preventDefault() - const content = await Filesystem.readArrayBuffer(filepath) + const content = await Filesystem.readArrayBuffer(localPath) .then((buffer) => Buffer.from(buffer).toString("base64")) .catch(() => {}) if (content) { @@ -961,13 +972,19 @@ export function Prompt(props: PromptProps) { } catch {} } - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + const lineCount = (decodedPastedContent.match(/\n/g)?.length ?? 0) + 1 if ( - (lineCount >= 3 || pastedContent.length > 150) && + (lineCount >= 3 || decodedPastedContent.length > 150) && !sync.data.config.experimental?.disable_paste_summary ) { event.preventDefault() - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + pasteText(decodedPastedContent, `[Pasted ~${lineCount} lines]`) + return + } + + if (decodedPastedContent !== pastedContent) { + event.preventDefault() + input.insertText(decodedPastedContent) return } diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index c011f6c6246..ec375090067 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -14,6 +14,7 @@ import { usePromptRef } from "../context/prompt" import { Installation } from "@/installation" import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" +import { decodeFileUrisInText } from "@opencode-ai/util/path" // TODO: what is the best way to do this? let once = false @@ -79,10 +80,14 @@ export function Home() { onMount(() => { if (once) return if (route.initialPrompt) { - prompt.set(route.initialPrompt) + const next = { + ...route.initialPrompt, + input: decodeFileUrisInText(route.initialPrompt.input), + } + prompt.set(next) once = true } else if (args.prompt) { - prompt.set({ input: args.prompt, parts: [] }) + prompt.set({ input: decodeFileUrisInText(args.prompt), parts: [] }) once = true prompt.submit() } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 68f6796cddd..0c9fd32ce53 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -81,6 +81,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { decodeFileUrisInText } from "@opencode-ai/util/path" addDefaultParsers(parsers.parsers) @@ -160,6 +161,13 @@ export function Session() { const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) + const normalizeInitialPrompt = (promptInfo: PromptInfo) => { + return { + ...promptInfo, + input: decodeFileUrisInText(promptInfo.input), + } + } + const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { if (session()?.parentID) return false @@ -204,7 +212,7 @@ export function Session() { // Handle initial prompt from fork createEffect(() => { if (route.initialPrompt && prompt) { - prompt.set(route.initialPrompt) + prompt.set(normalizeInitialPrompt(route.initialPrompt)) } }) @@ -1161,7 +1169,7 @@ export function Session() { promptRef.set(r) // Apply initial prompt when prompt component mounts (e.g., from fork) if (route.initialPrompt) { - r.set(route.initialPrompt) + r.set(normalizeInitialPrompt(route.initialPrompt)) } }} disabled={permissions().length > 0 || questions().length > 0} diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index bb191f5120a..0b3c6f0101a 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -35,3 +35,39 @@ export function truncateMiddle(text: string, maxLength: number = 20) { const end = Math.floor(available / 2) return text.slice(0, start) + "…" + text.slice(-end) } + +const FILE_URI_PATTERN = /file:\/\/[^\s<>()`"']+/g +const PERCENT_RUN_PATTERN = /(?:%[0-9A-Fa-f]{2})+/g + +function decodePercentRunIfUnicode(encoded: string) { + try { + const decoded = decodeURIComponent(encoded) + return /[^\x00-\x7F]/.test(decoded) ? decoded : encoded + } catch { + return encoded + } +} + +export function decodeFileUriForDisplay(uri: string) { + if (!uri.startsWith("file://")) return uri + + const queryIndex = uri.indexOf("?") + const hashIndex = uri.indexOf("#") + const boundary = + queryIndex === -1 + ? hashIndex === -1 + ? uri.length + : hashIndex + : hashIndex === -1 + ? queryIndex + : Math.min(queryIndex, hashIndex) + + const base = uri.slice(0, boundary) + const suffix = uri.slice(boundary) + const path = base.slice("file://".length).replace(PERCENT_RUN_PATTERN, decodePercentRunIfUnicode) + return `file://${path}${suffix}` +} + +export function decodeFileUrisInText(text: string) { + return text.replace(FILE_URI_PATTERN, decodeFileUriForDisplay) +}