diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 183c1555bde..4a3e276724d 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -19,6 +19,14 @@ import { import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" +function pathToFileUrl(filepath: string): string { + const encodedPath = filepath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/") + return `file://${encodedPath}` +} + type Kind = "add" | "del" | "mix" type Filter = { @@ -247,7 +255,7 @@ export default function FileTree(props: { onDragStart={(e: DragEvent) => { if (!draggable()) return e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`) + e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" const dragImage = document.createElement("div") diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 4cf2f29acfe..7010a1fd844 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -30,6 +30,12 @@ type BuildRequestPartsInput = { const absolute = (directory: string, path: string) => path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/") +const encodeFilePath = (filepath: string): string => + filepath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/") + const fileQuery = (selection: FileSelection | undefined) => selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" @@ -99,7 +105,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) { id: Identifier.ascending("part"), type: "file", mime: "text/plain", - url: `file://${path}${fileQuery(attachment.selection)}`, + url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, filename: getFilename(attachment.path), source: { type: "file", @@ -129,7 +135,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) { const used = new Set(files.map((part) => part.url)) const context = input.context.flatMap((item) => { const path = absolute(input.sessionDirectory, item.path) - const url = `file://${path}${fileQuery(item.selection)}` + const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}` const comment = item.comment?.trim() if (!comment && used.has(url)) return [] used.add(url) diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index ced30d0fdd0..155f05aafa4 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -72,12 +72,27 @@ export function unquoteGitPath(input: string) { return new TextDecoder().decode(new Uint8Array(bytes)) } +export function decodeFilePath(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export function encodeFilePath(filepath: string): string { + return filepath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/") +} + export function createPathHelpers(scope: () => string) { const normalize = (input: string) => { const root = scope() const prefix = root.endsWith("/") ? root : root + "/" - let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input))) + let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))) if (path.startsWith(prefix)) { path = path.slice(prefix.length) @@ -100,7 +115,7 @@ export function createPathHelpers(scope: () => string) { const tab = (input: string) => { const path = normalize(input) - return `file://${path}` + return `file://${encodeFilePath(path)}` } const pathFromTab = (tabValue: string) => { diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 775acc52a50..f38731676ce 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -29,6 +29,7 @@ import { } from "@agentclientprotocol/sdk" import { Log } from "../util/log" +import { pathToFileURL } from "bun" import { ACPSessionManager } from "./session" import type { ACPConfig } from "./types" import { Provider } from "../provider/provider" @@ -986,7 +987,7 @@ export namespace ACP { type: "image", mimeType: effectiveMime, data: base64Data, - uri: `file://${filename}`, + uri: pathToFileURL(filename).href, }, }, }) @@ -996,13 +997,14 @@ export namespace ACP { } else { // Non-image: text types get decoded, binary types stay as blob const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" + const fileUri = pathToFileURL(filename).href const resource = isText ? { - uri: `file://${filename}`, + uri: fileUri, mimeType: effectiveMime, text: Buffer.from(base64Data, "base64").toString("utf-8"), } - : { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data } + : { uri: fileUri, mimeType: effectiveMime, blob: base64Data } await this.connection .sessionUpdate({ @@ -1544,7 +1546,7 @@ export namespace ACP { const name = path.split("/").pop() || path return { type: "file", - url: `file://${path}`, + url: pathToFileURL(path).href, filename: name, mime: "text/plain", } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 6960ffd553f..60e4ce1cd19 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,5 +1,6 @@ import type { Argv } from "yargs" import path from "path" +import { pathToFileURL } from "bun" import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" @@ -310,7 +311,7 @@ export const RunCommand = cmd({ files.push({ type: "file", - url: `file://${resolvedPath}`, + url: pathToFileURL(resolvedPath).href, filename: path.basename(resolvedPath), mime, }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index e2ab579a979..f3cd54db6e4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -1,4 +1,5 @@ import { TextAttributes } from "@opentui/core" +import { fileURLToPath } from "bun" import { useTheme } from "../context/theme" import { useDialog } from "@tui/ui/dialog" import { useSync } from "@tui/context/sync" @@ -19,7 +20,7 @@ export function DialogStatus() { const list = sync.data.config.plugin ?? [] const result = list.map((value) => { if (value.startsWith("file://")) { - const path = value.substring("file://".length) + const path = fileURLToPath(value) const parts = path.split("/") const filename = parts.pop() || path if (!filename.includes(".")) return { name: filename } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 455fccb8c57..42cf82b421b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,4 +1,5 @@ import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" +import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" @@ -246,17 +247,17 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...sortedFiles.map((item): AutocompleteOption => { - let url = `file://${process.cwd()}/${item}` + const fullPath = `${process.cwd()}/${item}` + const urlObj = pathToFileURL(fullPath) let filename = item if (lineRange && !item.endsWith("/")) { filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` - const urlObj = new URL(url) urlObj.searchParams.set("start", String(lineRange.startLine)) if (lineRange.endLine !== undefined) { urlObj.searchParams.set("end", String(lineRange.endLine)) } - url = urlObj.toString() } + const url = urlObj.href const isDir = item.endsWith("/") return { diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 0fd3b69dfcd..9d7d30632ab 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -3,7 +3,7 @@ import { Bus } from "@/bus" import { Log } from "../util/log" import { LSPClient } from "./client" import path from "path" -import { pathToFileURL } from "url" +import { pathToFileURL, fileURLToPath } from "url" import { LSPServer } from "./server" import z from "zod" import { Config } from "../config/config" @@ -369,7 +369,7 @@ export namespace LSP { } export async function documentSymbol(uri: string) { - const file = new URL(uri).pathname + const file = fileURLToPath(uri) return run(file, (client) => client.connection .sendRequest("textDocument/documentSymbol", { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bcfccfb3e64..1302c736e3a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -32,7 +32,7 @@ import { Flag } from "../flag/flag" import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" -import { $, fileURLToPath } from "bun" +import { $, fileURLToPath, pathToFileURL } from "bun" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" @@ -210,7 +210,7 @@ export namespace SessionPrompt { if (stats.isDirectory()) { parts.push({ type: "file", - url: `file://${filepath}`, + url: pathToFileURL(filepath).href, filename: name, mime: "application/x-directory", }) @@ -219,7 +219,7 @@ export namespace SessionPrompt { parts.push({ type: "file", - url: `file://${filepath}`, + url: pathToFileURL(filepath).href, filename: name, mime: "text/plain", }) diff --git a/packages/opencode/test/session/prompt-special-chars.test.ts b/packages/opencode/test/session/prompt-special-chars.test.ts new file mode 100644 index 00000000000..dce0b004950 --- /dev/null +++ b/packages/opencode/test/session/prompt-special-chars.test.ts @@ -0,0 +1,56 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { fileURLToPath } from "url" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("session.prompt special characters", () => { + test("handles filenames with # character", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "file#name.txt"), "special content\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const template = "Read @file#name.txt" + const parts = await SessionPrompt.resolvePromptParts(template) + const fileParts = parts.filter((part) => part.type === "file") + + expect(fileParts.length).toBe(1) + expect(fileParts[0].filename).toBe("file#name.txt") + + // Verify the URL is properly encoded (# should be %23) + expect(fileParts[0].url).toContain("%23") + + // Verify the URL can be correctly converted back to a file path + const decodedPath = fileURLToPath(fileParts[0].url) + expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt")) + + const message = await SessionPrompt.prompt({ + sessionID: session.id, + parts, + noReply: true, + }) + const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + + // Verify the file content was read correctly + const textParts = stored.parts.filter((part) => part.type === "text") + const hasContent = textParts.some((part) => part.text.includes("special content")) + expect(hasContent).toBe(true) + + await Session.remove(session.id) + }, + }) + }) +}) diff --git a/packages/sdk/js/example/example.ts b/packages/sdk/js/example/example.ts index 481fc424023..42838a82a7e 100644 --- a/packages/sdk/js/example/example.ts +++ b/packages/sdk/js/example/example.ts @@ -1,4 +1,5 @@ import { createOpencodeClient, createOpencodeServer } from "@opencode-ai/sdk" +import { pathToFileURL } from "bun" const server = await createOpencodeServer() const client = createOpencodeClient({ baseUrl: server.url }) @@ -17,7 +18,7 @@ for await (const file of input) { { type: "file", mime: "text/plain", - url: `file://${file}`, + url: pathToFileURL(file).href, }, { type: "text", @@ -41,7 +42,7 @@ await Promise.all( { type: "file", mime: "text/plain", - url: `file://${file}`, + url: pathToFileURL(file).href, }, { type: "text", diff --git a/script/duplicate-pr.ts b/script/duplicate-pr.ts index aba078cecf7..b77737c1d41 100755 --- a/script/duplicate-pr.ts +++ b/script/duplicate-pr.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import path from "path" +import { pathToFileURL } from "bun" import { createOpencode } from "@opencode-ai/sdk" import { parseArgs } from "util" @@ -49,7 +50,7 @@ Examples: } parts.push({ type: "file", - url: `file://${resolved}`, + url: pathToFileURL(resolved).href, filename: path.basename(resolved), mime: "text/plain", })