From e65ad952749532a6cc69cbccce96605ffd0f8728 Mon Sep 17 00:00:00 2001 From: 477174 <477174.dev@gmail.com> Date: Mon, 2 Mar 2026 13:02:54 -0300 Subject: [PATCH 1/2] feat(tui): support Windows and WSL UNC paths in file drag-and-drop When running opencode in WSL, dragging files from Windows Explorer pastes Windows-style paths (C:\Users\...) or WSL UNC paths (\\wsl$\distro\...) which were not recognized by the paste handler. - Add isWindowsPath/isFilePath/resolveDroppedPath helpers for platform-aware path detection and conversion - Update all path detection guards in onPaste to accept Windows paths - Convert drive-letter paths to /mnt//... on WSL - Convert \\wsl$/\\wsl.localhost UNC paths to native WSL paths - Strip double quotes from dropped paths (Windows terminal behavior) - Pass-through on native Windows and other platforms (no behavior change) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../cli/cmd/tui/component/prompt/index.tsx | 100 ++++++++++++------ 1 file changed, 70 insertions(+), 30 deletions(-) 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 5c32d2a4403a..318f8aaf655e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -4,6 +4,7 @@ import "opentui-spinner/solid" import path from "path" import { pathToFileURL } from "bun" import { existsSync } from "fs" +import { platform, release } from "os" import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" @@ -94,6 +95,44 @@ function shellTokens(s: string): string[] { return tokens } +// matches C:\, D:/, etc. +const WIN_DRIVE_RE = /^[A-Za-z]:[\\\/]/ +// matches \\wsl$\distro\... and \\wsl.localhost\distro\... +const WSL_UNC_RE = /^[\\\/]{2}wsl([.$]localhost)?[\\\/]/i + +function isWindowsPath(p: string): boolean { + return WIN_DRIVE_RE.test(p) || WSL_UNC_RE.test(p) +} + +function isFilePath(p: string): boolean { + return p.startsWith("/") || p.startsWith("~/") || p.startsWith("file://") || isWindowsPath(p) +} + +// C:\Users\foo → /mnt/c/Users/foo on WSL +// \\wsl$\Ubuntu\home\foo → /home/foo on WSL +// pass-through on win32 and other platforms +function resolveDroppedPath(p: string): string { + if (!isWindowsPath(p)) return p + const os = platform() + if (os === "win32") return p + + const rel = release().toLowerCase() + const isWSL = rel.includes("wsl") || rel.includes("microsoft") + if (!isWSL) return p + + if (WSL_UNC_RE.test(p)) { + const normalized = p.replace(/\\/g, "/") + const parts = normalized.split("/").filter(Boolean) + // parts: ["wsl$" or "wsl.localhost", "", "home", "foo", ...] + const nativePath = "/" + parts.slice(2).join("/") + return nativePath + } + + const drive = p[0].toLowerCase() + const rest = p.slice(2).replace(/\\/g, "/") + return `/mnt/${drive}${rest}` +} + export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -774,8 +813,8 @@ export function Prompt(props: PromptProps) { } async function pasteFile(filepath: string): Promise { - // 1. Normalize path let fp = filepath + fp = resolveDroppedPath(fp) 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) @@ -1020,22 +1059,21 @@ export function Prompt(props: PromptProps) { return } - // trim ' from the beginning and end of the pasted content. just - // ' and nothing else - const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + 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 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 + const normalized = resolveDroppedPath(fp) + if (normalized.startsWith("file://")) return decodeURIComponent(new URL(normalized).pathname) + if (normalized.startsWith("~/")) return path.join(process.env["HOME"] ?? "~", normalized.slice(2)) + if (!path.isAbsolute(normalized)) return path.resolve(sync.data.path.directory || process.cwd(), normalized) + return normalized }) - if (fps.every((fp) => fp.startsWith("/") || fp.startsWith("~/") || fp.startsWith("file://"))) { + if (fps.every((fp) => isFilePath(fp))) { event.preventDefault() const allExist = await Promise.all(resolved.map((fp) => Filesystem.exists(fp))) if (allExist.every(Boolean)) { @@ -1070,13 +1108,14 @@ export function Prompt(props: PromptProps) { // (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://"))) { + if (tokens.length > 1 && tokens.every((t) => isFilePath(t))) { 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 normalized = resolveDroppedPath(t) + if (normalized.startsWith("file://")) return decodeURIComponent(new URL(normalized).pathname) + if (normalized.startsWith("~/")) return path.join(process.env["HOME"] ?? "~", normalized.slice(2)) + if (!path.isAbsolute(normalized)) return path.resolve(sync.data.path.directory || process.cwd(), normalized) + return normalized }) const allExist = await Promise.all(resolved.map((fp) => Filesystem.exists(fp))) if (allExist.every(Boolean)) { @@ -1100,12 +1139,13 @@ export function Prompt(props: PromptProps) { // 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 (isFilePath(t)) { + const normalized = resolveDroppedPath(t) + const fp = normalized.startsWith("file://") + ? decodeURIComponent(new URL(normalized).pathname) + : normalized.startsWith("~/") + ? path.join(process.env["HOME"] ?? "~", normalized.slice(2)) + : normalized if (existsSync(fp)) { event.preventDefault() const mime = Filesystem.mimeType(fp) @@ -1123,15 +1163,15 @@ export function Prompt(props: PromptProps) { } } } - const isUrl = /^(https?):\/\//.test(filepath) + const resolvedFilepath = resolveDroppedPath(filepath) + const isUrl = /^(https?):\/\//.test(resolvedFilepath) if (!isUrl) { try { - const mime = Filesystem.mimeType(filepath) - const filename = path.basename(filepath) - // Handle SVG as raw text content, not as base64 image + const mime = Filesystem.mimeType(resolvedFilepath) + const filename = path.basename(resolvedFilepath) if (mime === "image/svg+xml") { event.preventDefault() - const content = await Filesystem.readText(filepath).catch(() => {}) + const content = await Filesystem.readText(resolvedFilepath).catch(() => {}) if (content) { pasteText(content, `[SVG: ${filename ?? "image"}]`) return @@ -1139,7 +1179,7 @@ export function Prompt(props: PromptProps) { } if (mime.startsWith("image/")) { event.preventDefault() - const content = await Filesystem.readArrayBuffer(filepath) + const content = await Filesystem.readArrayBuffer(resolvedFilepath) .then((buffer) => Buffer.from(buffer).toString("base64")) .catch(() => {}) if (content) { @@ -1151,9 +1191,9 @@ export function Prompt(props: PromptProps) { return } } - const resolved = path.isAbsolute(filepath) - ? filepath - : path.resolve(sync.data.path.directory || process.cwd(), filepath) + const resolved = path.isAbsolute(resolvedFilepath) + ? resolvedFilepath + : path.resolve(sync.data.path.directory || process.cwd(), resolvedFilepath) if (existsSync(resolved)) { event.preventDefault() await pasteFile(resolved) From 19ee0fdb560311541052447c58e33618e467a297 Mon Sep 17 00:00:00 2001 From: 477174 <477174.dev@gmail.com> Date: Mon, 2 Mar 2026 13:17:04 -0300 Subject: [PATCH 2/2] fix(tui): fix clipboard image paste on WSL via WSLg Wayland bridge PowerShell processes spawned from WSL cannot access the Windows clipboard session (fundamental session isolation). The previous code tried PowerShell on WSL, which always failed silently. - Skip PowerShell clipboard read on WSL (dead code, ~500ms wasted) - Fix wl-paste/wl-copy on WSLg: override XDG_RUNTIME_DIR to /mnt/wslg/runtime-dir where the Wayland socket actually lives - Requires wl-clipboard package (apt install wl-clipboard) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../src/cli/cmd/tui/util/clipboard.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index f5d4835af08d..70a48ad45083 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -4,8 +4,19 @@ import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" +import { existsSync } from "fs" import { Filesystem } from "../../../../util/filesystem" +const isWSL = platform() === "linux" && /wsl|microsoft/i.test(release()) + +// WSLg Wayland socket lives at /mnt/wslg/runtime-dir, not XDG_RUNTIME_DIR +function wslgRuntimeDir(): string | undefined { + if (!isWSL) return undefined + const wslgDir = "/mnt/wslg/runtime-dir" + if (existsSync(wslgDir + "/wayland-0")) return wslgDir + return undefined +} + /** * Writes text to clipboard via OSC 52 escape sequence. * This allows clipboard operations to work over SSH by having @@ -43,7 +54,8 @@ export namespace Clipboard { } } - if (os === "win32" || release().includes("WSL")) { + // PowerShell clipboard: only on native Windows (on WSL, processes can't access the Windows clipboard session) + if (os === "win32") { const script = "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }" const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text() @@ -57,8 +69,11 @@ export namespace Clipboard { if (os === "linux") { 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() + const wlPasteAvailable = process.env["WAYLAND_DISPLAY"] && Bun.which("wl-paste") + if (wlPasteAvailable) { + const runtimeDir = wslgRuntimeDir() + const wlEnv = runtimeDir ? { ...process.env, XDG_RUNTIME_DIR: runtimeDir } : process.env + const types = await $`wl-paste --list-types`.env(wlEnv).nothrow().quiet().text() if (types) { const available = types .split("\n") @@ -66,7 +81,7 @@ export namespace Clipboard { .filter(Boolean) for (const mime of mimePriority) { if (available.includes(mime)) { - const data = await $`wl-paste -t ${mime}`.nothrow().quiet().arrayBuffer() + const data = await $`wl-paste -t ${mime}`.env(wlEnv).nothrow().quiet().arrayBuffer() if (data && data.byteLength > 0) { return { data: Buffer.from(data).toString("base64"), mime } } @@ -112,8 +127,10 @@ export namespace Clipboard { if (os === "linux") { if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) { console.log("clipboard: using wl-copy") + const runtimeDir = wslgRuntimeDir() + const env = runtimeDir ? { ...process.env, XDG_RUNTIME_DIR: runtimeDir } : undefined return async (text: string) => { - const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) + const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore", env }) proc.stdin.write(text) proc.stdin.end() await proc.exited.catch(() => {})