summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKhang Ha (Kelvin) <[email protected]>2026-02-07 05:16:56 +0700
committerGitHub <[email protected]>2026-02-06 16:16:56 -0600
commitfde0b39b7c97dacb78cb55f3d963aa54f61650ea (patch)
tree066ebb3f0a2716c9e0ee8e72b55239ad249cd5d1 /packages
parente9a3cfc083bf480ba2c8aaa585a4e914549e3e56 (diff)
downloadopencode-fde0b39b7c97dacb78cb55f3d963aa54f61650ea.tar.gz
opencode-fde0b39b7c97dacb78cb55f3d963aa54f61650ea.zip
fix: properly encode file URLs with special characters (#12424)
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/components/file-tree.tsx10
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.ts10
-rw-r--r--packages/app/src/context/file/path.ts19
-rw-r--r--packages/opencode/src/acp/agent.ts10
-rw-r--r--packages/opencode/src/cli/cmd/run.ts3
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx3
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx7
-rw-r--r--packages/opencode/src/lsp/index.ts4
-rw-r--r--packages/opencode/src/session/prompt.ts6
-rw-r--r--packages/opencode/test/session/prompt-special-chars.test.ts56
-rw-r--r--packages/sdk/js/example/example.ts5
11 files changed, 112 insertions, 21 deletions
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
index 183c1555b..4a3e27672 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 4cf2f29ac..7010a1fd8 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 ced30d0fd..155f05aaf 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 775acc52a..f38731676 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 0eb09dd62..163a5820d 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"
@@ -314,7 +315,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 e2ab579a9..f3cd54db6 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 455fccb8c..42cf82b42 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 0fd3b69df..9d7d30632 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 92ddf8c5b..6113856cc 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 000000000..dce0b0049
--- /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 481fc4240..42838a82a 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",