Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 70 additions & 30 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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", "<distro>", "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
Expand Down Expand Up @@ -774,8 +813,8 @@ export function Prompt(props: PromptProps) {
}

async function pasteFile(filepath: string): Promise<void> {
// 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)
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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)
Expand All @@ -1123,23 +1163,23 @@ 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
}
}
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) {
Expand All @@ -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)
Expand Down
27 changes: 22 additions & 5 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -57,16 +69,19 @@ 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")
.map((x) => x.trim())
.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 }
}
Expand Down Expand Up @@ -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(() => {})
Expand Down