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) 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(() => {})