From dfb1132f46761441195bae989edcc96f710939ab Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Fri, 20 Feb 2026 22:00:53 -0300 Subject: [PATCH 1/7] fix(tui): support all image MIME types in Linux clipboard read --- .sisyphus/evidence/task-1-clipboard-empty.txt | 11 ++ .sisyphus/evidence/task-1-clipboard-jpeg.txt | 13 ++ .sisyphus/evidence/task-1-clipboard-png.txt | 11 ++ .sisyphus/evidence/task-1-clipboard-text.txt | 11 ++ .../notepads/tui-paste-and-drop/learnings.md | 148 ++++++++++++++++++ .../src/cli/cmd/tui/util/clipboard.ts | 40 ++++- 6 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 .sisyphus/evidence/task-1-clipboard-empty.txt create mode 100644 .sisyphus/evidence/task-1-clipboard-jpeg.txt create mode 100644 .sisyphus/evidence/task-1-clipboard-png.txt create mode 100644 .sisyphus/evidence/task-1-clipboard-text.txt create mode 100644 .sisyphus/notepads/tui-paste-and-drop/learnings.md diff --git a/.sisyphus/evidence/task-1-clipboard-empty.txt b/.sisyphus/evidence/task-1-clipboard-empty.txt new file mode 100644 index 000000000000..43d2a5fe9a0c --- /dev/null +++ b/.sisyphus/evidence/task-1-clipboard-empty.txt @@ -0,0 +1,11 @@ +Prerequisites +WAYLAND_DISPLAY= +which wl-paste -> not found +which wl-copy -> not found + +Scenario 3 (Empty clipboard) command output +zsh:1: command not found: wl-paste +no clipboard data (expected) + +Result +Empty clipboard behavior through wl-paste could not be verified directly because wl-paste is unavailable. diff --git a/.sisyphus/evidence/task-1-clipboard-jpeg.txt b/.sisyphus/evidence/task-1-clipboard-jpeg.txt new file mode 100644 index 000000000000..239043287d6e --- /dev/null +++ b/.sisyphus/evidence/task-1-clipboard-jpeg.txt @@ -0,0 +1,13 @@ +Prerequisites +WAYLAND_DISPLAY= +which wl-paste -> not found +which wl-copy -> not found +which convert -> not found + +Scenario 1 (JPEG) command output +zsh:1: command not found: wl-copy +zsh:1: command not found: wl-paste +zsh:1: command not found: wl-paste + +Result +Unable to validate JPEG clipboard MIME listing in this environment because Wayland clipboard tooling is unavailable. diff --git a/.sisyphus/evidence/task-1-clipboard-png.txt b/.sisyphus/evidence/task-1-clipboard-png.txt new file mode 100644 index 000000000000..881932df4257 --- /dev/null +++ b/.sisyphus/evidence/task-1-clipboard-png.txt @@ -0,0 +1,11 @@ +Prerequisites +WAYLAND_DISPLAY= +which wl-paste -> not found +which wl-copy -> not found +which convert -> not found + +Scenario 2 (PNG) command output +PNG scenario could not run: wl-copy/wl-paste unavailable in environment. + +Result +Unable to validate PNG backward compatibility in this environment because Wayland clipboard tooling is unavailable. diff --git a/.sisyphus/evidence/task-1-clipboard-text.txt b/.sisyphus/evidence/task-1-clipboard-text.txt new file mode 100644 index 000000000000..a9e8ec8cd71c --- /dev/null +++ b/.sisyphus/evidence/task-1-clipboard-text.txt @@ -0,0 +1,11 @@ +Prerequisites +WAYLAND_DISPLAY= +which wl-paste -> not found +which wl-copy -> not found + +Scenario 4 (Text clipboard) command output +zsh:1: command not found: wl-copy +zsh:1: command not found: wl-paste + +Result +Text clipboard type listing could not be verified because Wayland clipboard tooling is unavailable. diff --git a/.sisyphus/notepads/tui-paste-and-drop/learnings.md b/.sisyphus/notepads/tui-paste-and-drop/learnings.md new file mode 100644 index 000000000000..6530e8c0ab54 --- /dev/null +++ b/.sisyphus/notepads/tui-paste-and-drop/learnings.md @@ -0,0 +1,148 @@ +## [2026-02-21] Session ses_382961d1effek55DYc2WJeSChP — Atlas initialization + +### Key Architecture Facts + +- **data: vs file:// URLs**: Clipboard binary (Ctrl+V) → `data:` URL (base64 inline). Drag-dropped file paths → `file://` URL (deferred read at submit). NEVER mix these up. +- **pasteImage()**: Lines 696-737 in prompt/index.tsx. Uses `data:${mime};base64,${content}`. Pattern for extmark creation: `input.visualCursor.offset`, `input.extmarks.create({ start, end, virtual: true, styleId: pasteStyleId, typeId: promptPartTypeId })`, `setStore(produce(...))`. +- **onPaste handler**: Lines 914-981. Current bug: line 932 strips quotes/escapes for ONE filepath, then lines 935-961 handle it. Multi-line paste goes to `[Pasted ~N lines]` at line 964. +- **pathToFileURL**: Used in autocomplete.tsx line 2 (imported from "bun"). NOT imported in prompt/index.tsx — needs to be added. +- **Filesystem utilities**: `Filesystem.exists()`, `Filesystem.isDir()`, `Filesystem.mimeType()`, `Filesystem.readArrayBuffer()` — use these, NOT raw fs APIs. +- **clipboard.ts Linux bug**: Lines 58-67 hardcode `wl-paste -t image/png` with NO `$WAYLAND_DISPLAY` check, NO `Bun.which()` guard. Write side (lines 86-96) does check both. Fix: add same guards, add MIME discovery via `wl-paste --list-types` / `xclip -t TARGETS -o`. + +### Existing Single-Path Parse Pattern (must preserve): +```ts +// line 932 — applied per-line in multi-file detection: +const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") +``` + +### File Locations +- `clipboard.ts`: `packages/opencode/src/cli/cmd/tui/util/clipboard.ts` (159 lines) +- `prompt/index.tsx`: `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` (1155 lines) +- `autocomplete.tsx`: `packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx` +- `filesystem.ts`: `packages/opencode/src/util/filesystem.ts` +- Build: `./packages/opencode/script/build.ts --single` from repo root +- Typecheck: `bun run typecheck` from repo root + +## [2026-02-20] Task 1 — Linux clipboard MIME discovery + +- Updated `Clipboard.read()` Linux branch to discover MIME types dynamically before reading image data. +- Detection now mirrors write-side style: Wayland uses `process.env["WAYLAND_DISPLAY"] && Bun.which("wl-paste")`; X11 fallback uses `Bun.which("xclip")` in `else if`. +- Added ordered MIME priority for reads: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/bmp`. +- All Linux subprocesses now use `.nothrow().quiet()` and return actual discovered MIME instead of hardcoded `image/png`. +- `bun run typecheck` passed from repo root after the change. +- Environment lacked `WAYLAND_DISPLAY`, `wl-copy`, `wl-paste`, and `convert`, so runtime clipboard QA evidence files were captured as blocked-by-environment outputs. + +## [2026-02-21] Task 3 — WezTerm Drag-and-Drop Format Research + +### Key Discovery: `quote_dropped_files` Configuration + +WezTerm's drag-and-drop is **configurable** via `quote_dropped_files` setting (since v20220624-141144-bd1b7c5d): +- **Default (Linux/macOS)**: `"SpacesOnly"` — backslash-escape spaces only +- **Windows default**: `"Windows"` — double-quote if spaces +- **Other modes**: `"None"`, `"Posix"`, `"WindowsAlwaysQuoted"` + +### Format Evidence + +**Single file**: `/path/to/file` or `/path/to/my\ file.txt` (backslash-escaped spaces) + +**Multiple files**: **Newline-separated** (one per line) +``` +/home/user/file1.png +/home/user/file2.pdf +/home/user/my\ file.txt +``` + +**NOT file:// URIs** — WezTerm sends bare absolute paths, not `file://` format. + +### Validation Against Existing Code + +Line 932 in `prompt/index.tsx`: +```typescript +const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") +``` + +This **exactly matches** WezTerm's `SpacesOnly` mode: +1. Strips single quotes (if present) +2. Unescapes backslash-spaces to spaces + +### Task 4 Recommendation + +For multi-file parsing, apply the same per-line logic: +```typescript +const lines = pastedContent.split('\n').filter(line => line.trim()) +const filepaths = lines.map(line => + line.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") +) +``` + +This handles: +- ✅ SpacesOnly mode (default) +- ✅ Posix mode (if user configured) +- ✅ Single files (one line) +- ✅ Multiple files (newline-separated) +- ✅ Mixed quoting per line +- ✅ Empty lines (filtered) + +### Sources + +- WezTerm docs: https://wezterm.org/config/lua/config/quote_dropped_files.html +- GitHub #640: Drag & Drop files/folders (closed, implemented) +- WezTerm v20240203-110809-5046fc22 (current environment) +- Evidence file: `.sisyphus/evidence/task-3-wezterm-format.md` + +## [2026-02-21] Task 2 — pasteFile() Implementation Complete + +### Implementation Summary +- **File**: `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` +- **Lines**: 740-793 (54 lines) +- **Import added**: Line 5 — `import { pathToFileURL } from "bun"` + +### Key Implementation Details + +#### Path Normalization (lines 742-745) +```ts +let fp = filepath +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) +``` +- Handles file:// URLs from drag-drop +- Expands tilde (~) to HOME +- Resolves relative paths against working directory + +#### Validation & Metadata (lines 748-754) +- Silent return if file doesn't exist (no error toast) +- Detects directories vs files +- Uses `Filesystem.mimeType()` for MIME detection +- Virtual text: `[File: name]` or `[Dir: name/]` + +#### Extmark Creation (lines 757-769) +- **Identical to pasteImage()** pattern +- Uses `input.visualCursor.offset` for positioning +- Registers with `pasteStyleId` and `promptPartTypeId` +- Stores extmark→part mapping + +#### FilePart Creation (lines 775-785) +- **URL**: `pathToFileURL(fp).href` → `file:///absolute/path` +- **NOT** `data:` URL (key difference from pasteImage) +- **MIME**: `"inode/directory"` for dirs +- **source.path**: Relative path from working directory + +### Pattern Consistency +✅ Matches pasteImage() exactly for: +- Extmark creation structure +- setStore(produce(...)) pattern +- Virtual text positioning +- Part index tracking + +### Differences from pasteImage() +- Uses `file://` URLs instead of `data:` URLs +- Handles directory detection +- Validates file existence +- Normalizes paths (tilde, relative, file://) + +### Ready for Integration +The function is now ready to be called from: +1. `onPaste` handler (for pasted file paths) +2. Drag-drop handler (for dropped files) +3. Task 4 will route image files to pasteImage(), non-images to pasteFile() diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 7d1aad3a86e8..f5d4835af08d 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -56,13 +56,39 @@ export namespace Clipboard { } if (os === "linux") { - const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer() - if (wayland && wayland.byteLength > 0) { - return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" } - } - const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer() - if (x11 && x11.byteLength > 0) { - return { data: Buffer.from(x11).toString("base64"), mime: "image/png" } + 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() + 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() + if (data && data.byteLength > 0) { + return { data: Buffer.from(data).toString("base64"), mime } + } + } + } + } + } else if (Bun.which("xclip")) { + const targets = await $`xclip -selection clipboard -t TARGETS -o`.nothrow().quiet().text() + if (targets) { + const available = targets + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + for (const mime of mimePriority) { + if (available.includes(mime)) { + const data = await $`xclip -selection clipboard -t ${mime} -o`.nothrow().quiet().arrayBuffer() + if (data && data.byteLength > 0) { + return { data: Buffer.from(data).toString("base64"), mime } + } + } + } + } } } From 9d48820610c1fb6390b5d5c4ca3c8147d55e4bbf Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Fri, 20 Feb 2026 22:08:42 -0300 Subject: [PATCH 2/7] feat(tui): rewrite onPaste handler for multi-file paste and drag-and-drop --- .../notepads/tui-paste-and-drop/learnings.md | 10 +++ .../cli/cmd/tui/component/prompt/index.tsx | 86 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/.sisyphus/notepads/tui-paste-and-drop/learnings.md b/.sisyphus/notepads/tui-paste-and-drop/learnings.md index 6530e8c0ab54..0dc747fb04e1 100644 --- a/.sisyphus/notepads/tui-paste-and-drop/learnings.md +++ b/.sisyphus/notepads/tui-paste-and-drop/learnings.md @@ -146,3 +146,13 @@ The function is now ready to be called from: 1. `onPaste` handler (for pasted file paths) 2. Drag-drop handler (for dropped files) 3. Task 4 will route image files to pasteImage(), non-images to pasteFile() + +## [2026-02-21] Task 4 — onPaste multi-file path attach + +- Inserted a multi-file detection block in `prompt/index.tsx` immediately after `filepath` normalization and before `isUrl` detection. +- Logic now splits paste payload by newline, trims/filters empty lines, applies existing per-line normalization (`strip quotes`, `unescape \ `), resolves `file://`, `~/`, and relative paths, then checks `Filesystem.exists()` for all lines. +- When all lines resolve to existing paths, paste is intercepted and each path is attached independently: + - raster images (`image/*` except SVG) are loaded as base64 and sent through `pasteImage()` + - everything else routes through `pasteFile()` (including directories and SVG) +- If any line is not a valid existing path, handler falls through to existing behavior unchanged: URL/single-file checks and `[Pasted ~N lines]` summary for multiline text. +- `bun run typecheck` from repo root passed (`Tasks: 12 successful, 12 total`). 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 d63c248fb83e..fd660b734dfc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -2,6 +2,7 @@ import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" +import { pathToFileURL } from "bun" import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" @@ -736,6 +737,61 @@ export function Prompt(props: PromptProps) { return } + async function pasteFile(filepath: string): Promise { + // 1. Normalize path + let fp = filepath + 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) + + // 2. Validate — return silently if not found + if (!(await Filesystem.exists(fp))) return + + // 3. Get metadata + const isDir = await Filesystem.isDir(fp) + const name = path.basename(fp) + const mime = isDir ? "inode/directory" : Filesystem.mimeType(fp) + const virtualText = isDir ? `[Dir: ${name}/]` : `[File: ${name}]` + + // 4. Create extmark (same pattern as pasteImage) + const currentOffset = input.visualCursor.offset + const extmarkStart = currentOffset + const extmarkEnd = extmarkStart + virtualText.length + + input.insertText(virtualText + " ") + + const extmarkId = input.extmarks.create({ + start: extmarkStart, + end: extmarkEnd, + virtual: true, + styleId: pasteStyleId, + typeId: promptPartTypeId, + }) + + // 5. Create FilePart with file:// URL (NOT data: URL) + const url = pathToFileURL(fp).href + const relPath = path.relative(sync.data.path.directory || process.cwd(), fp) + + const part: Omit = { + type: "file" as const, + mime, + filename: name, + url, + source: { + type: "file", + path: relPath, + text: { start: extmarkStart, end: extmarkEnd, value: virtualText }, + }, + } + setStore( + produce((draft) => { + const partIndex = draft.prompt.parts.length + draft.prompt.parts.push(part) + draft.extmarkToPartIndex.set(extmarkId, partIndex) + }), + ) + } + const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary @@ -930,6 +986,36 @@ export function Prompt(props: PromptProps) { // trim ' from the beginning and end of the pasted content. just // ' and nothing else 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 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 allExist = await Promise.all(resolved.map((fp) => Filesystem.exists(fp))) + if (allExist.every(Boolean)) { + event.preventDefault() + for (const fp of resolved) { + const mime = Filesystem.mimeType(fp) + if (mime.startsWith("image/") && mime !== "image/svg+xml") { + const filename = path.basename(fp) + const content = await Filesystem.readArrayBuffer(fp) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => undefined) + if (content) await pasteImage({ filename, mime, content }) + } else { + await pasteFile(fp) + } + } + return + } + } const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { try { From 8245423e22a17b76973bfa17159a6decb46cb63c Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Fri, 20 Feb 2026 23:07:53 -0300 Subject: [PATCH 3/7] fix(tui): call preventDefault synchronously in multi-file paste handler event.preventDefault() must be called synchronously before yielding to the event loop. The previous code called it after awaiting Filesystem.exists(), allowing the browser default paste to insert raw file paths into the textarea alongside the virtual extmarks. Fix: add a synchronous heuristic check (paths start with /, ~/, or file://) to call preventDefault() immediately, then validate existence asynchronously. If async validation fails, manually insert text as fallback since the default paste was already prevented. --- .../cli/cmd/tui/component/prompt/index.tsx | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 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 fd660b734dfc..fa67b2c3b9c2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -998,20 +998,32 @@ export function Prompt(props: PromptProps) { if (!path.isAbsolute(fp)) return path.resolve(sync.data.path.directory || process.cwd(), fp) return fp }) - const allExist = await Promise.all(resolved.map((fp) => Filesystem.exists(fp))) - if (allExist.every(Boolean)) { + if (fps.every((fp) => fp.startsWith("/") || fp.startsWith("~/") || fp.startsWith("file://"))) { event.preventDefault() - for (const fp of resolved) { - const mime = Filesystem.mimeType(fp) - if (mime.startsWith("image/") && mime !== "image/svg+xml") { - const filename = path.basename(fp) - const content = await Filesystem.readArrayBuffer(fp) - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => undefined) - if (content) await pasteImage({ filename, mime, content }) - } else { - await pasteFile(fp) + const allExist = await Promise.all(resolved.map((fp) => Filesystem.exists(fp))) + if (allExist.every(Boolean)) { + for (const fp of resolved) { + const mime = Filesystem.mimeType(fp) + if (mime.startsWith("image/") && mime !== "image/svg+xml") { + const filename = path.basename(fp) + const content = await Filesystem.readArrayBuffer(fp) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => undefined) + if (content) await pasteImage({ filename, mime, content }) + } else { + await pasteFile(fp) + } } + return + } + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ( + (lineCount >= 3 || pastedContent.length > 150) && + !sync.data.config.experimental?.disable_paste_summary + ) { + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + } else { + input.insertText(pastedContent) } return } From f21fbe3be0734a7c5695614fedeecd52e9be33c2 Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Sat, 21 Feb 2026 00:02:58 -0300 Subject: [PATCH 4/7] fix(tui): route single non-image file paths to pasteFile handler Single file paths for PDFs, code files, and directories were falling through to the text handler because only image MIME types were checked. Uses existsSync for synchronous preventDefault before any await. --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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 fa67b2c3b9c2..69d27da7be83 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -3,6 +3,7 @@ import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, o import "opentui-spinner/solid" import path from "path" import { pathToFileURL } from "bun" +import { existsSync } from "fs" import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" @@ -1056,6 +1057,11 @@ export function Prompt(props: PromptProps) { return } } + if (existsSync(filepath)) { + event.preventDefault() + await pasteFile(filepath) + return + } } catch {} } From faf239d99dde8f0121dccc1ddc7d0195cab6fbd4 Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Sat, 21 Feb 2026 08:52:42 -0300 Subject: [PATCH 5/7] feat(tui): handle WezTerm space-separated drag-and-drop file paths WezTerm sends dropped files as space-separated paths with backslash- escaped spaces (quote_dropped_files=SpacesOnly), not newline-separated. Add shellTokens() parser to handle all WezTerm quoting modes and integrate it into onPaste so multi-file DnD routes to pasteImage/ pasteFile correctly. Also removes debug logging from prior session. --- .../cli/cmd/tui/component/prompt/index.tsx | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) 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 69d27da7be83..21896a63f1c3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -60,6 +60,40 @@ export type PromptRef = { const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"] +// Tokenize shell-style strings: handles backslash-escaped spaces, +// double-quoted and single-quoted segments. Covers all WezTerm +// quote_dropped_files modes (SpacesOnly, Posix, Windows). +function shellTokens(s: string): string[] { + const tokens: string[] = [] + let cur = "" + let i = 0 + while (i < s.length) { + if (s[i] === "\\" && i + 1 < s.length) { + cur += s[i + 1] + i += 2 + } else if (s[i] === '"') { + i++ + while (i < s.length && s[i] !== '"') { + if (s[i] === "\\" && i + 1 < s.length) { cur += s[i + 1]; i += 2 } + else { cur += s[i]; i++ } + } + i++ + } else if (s[i] === "'") { + i++ + while (i < s.length && s[i] !== "'") { cur += s[i]; i++ } + i++ + } else if (s[i] === " " || s[i] === "\t") { + if (cur) { tokens.push(cur); cur = "" } + i++ + } else { + cur += s[i] + i++ + } + } + if (cur) tokens.push(cur) + return tokens +} + export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -80,6 +114,7 @@ export function Prompt(props: PromptProps) { const { theme, syntax } = useTheme() const kv = useKV() + function promptModelWarning() { toast.show({ variant: "warning", @@ -969,6 +1004,7 @@ export function Prompt(props: PromptProps) { }} onSubmit={submit} onPaste={async (event: PasteEvent) => { + if (props.disabled) { event.preventDefault() return @@ -1029,6 +1065,62 @@ export function Prompt(props: PromptProps) { return } } + + // WezTerm DnD: space-separated paths with backslash-escaped spaces + // (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://"))) { + 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 allExist = await Promise.all(resolved.map((fp) => Filesystem.exists(fp))) + if (allExist.every(Boolean)) { + event.preventDefault() + for (const fp of resolved) { + const mime = Filesystem.mimeType(fp) + if (mime.startsWith("image/") && mime !== "image/svg+xml") { + const filename = path.basename(fp) + const content = await Filesystem.readArrayBuffer(fp) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => undefined) + if (content) await pasteImage({ filename, mime, content }) + } else { + await pasteFile(fp) + } + } + return + } + } + // 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 (existsSync(fp)) { + event.preventDefault() + const mime = Filesystem.mimeType(fp) + if (mime.startsWith("image/") && mime !== "image/svg+xml") { + const filename = path.basename(fp) + const content = await Filesystem.readArrayBuffer(fp) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => undefined) + if (content) await pasteImage({ filename, mime, content }) + } else { + await pasteFile(fp) + } + return + } + } + } + } const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { try { From 314c3e2fb768079b71ed0786199ff27090e5874b Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Sat, 21 Feb 2026 09:59:14 -0300 Subject: [PATCH 6/7] fix(tui): call preventDefault synchronously in shell-token DnD handler Move event.preventDefault() before await in the WezTerm space-separated DnD branch to prevent raw path text from being inserted during the async gap. Add text fallback when file existence check fails. --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 21896a63f1c3..247305de4e62 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1071,6 +1071,7 @@ export function Prompt(props: PromptProps) { if (lines.length === 1) { const tokens = shellTokens(pastedContent) if (tokens.length > 1 && tokens.every((t) => t.startsWith("/") || t.startsWith("~/") || t.startsWith("file://"))) { + 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)) @@ -1079,7 +1080,6 @@ export function Prompt(props: PromptProps) { }) const allExist = await Promise.all(resolved.map((fp) => Filesystem.exists(fp))) if (allExist.every(Boolean)) { - event.preventDefault() for (const fp of resolved) { const mime = Filesystem.mimeType(fp) if (mime.startsWith("image/") && mime !== "image/svg+xml") { @@ -1094,6 +1094,8 @@ export function Prompt(props: PromptProps) { } return } + input.insertText(pastedContent) + return } // Single shell token that's a valid path (WezTerm DnD of 1 file) if (tokens.length === 1) { From b70f398b2dde092fefa25dcd8739e43370221a11 Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Sat, 21 Feb 2026 10:02:46 -0300 Subject: [PATCH 7/7] chore: remove working files not intended for upstream --- .sisyphus/evidence/task-1-clipboard-empty.txt | 11 -- .sisyphus/evidence/task-1-clipboard-jpeg.txt | 13 -- .sisyphus/evidence/task-1-clipboard-png.txt | 11 -- .sisyphus/evidence/task-1-clipboard-text.txt | 11 -- .../notepads/tui-paste-and-drop/learnings.md | 158 ------------------ 5 files changed, 204 deletions(-) delete mode 100644 .sisyphus/evidence/task-1-clipboard-empty.txt delete mode 100644 .sisyphus/evidence/task-1-clipboard-jpeg.txt delete mode 100644 .sisyphus/evidence/task-1-clipboard-png.txt delete mode 100644 .sisyphus/evidence/task-1-clipboard-text.txt delete mode 100644 .sisyphus/notepads/tui-paste-and-drop/learnings.md diff --git a/.sisyphus/evidence/task-1-clipboard-empty.txt b/.sisyphus/evidence/task-1-clipboard-empty.txt deleted file mode 100644 index 43d2a5fe9a0c..000000000000 --- a/.sisyphus/evidence/task-1-clipboard-empty.txt +++ /dev/null @@ -1,11 +0,0 @@ -Prerequisites -WAYLAND_DISPLAY= -which wl-paste -> not found -which wl-copy -> not found - -Scenario 3 (Empty clipboard) command output -zsh:1: command not found: wl-paste -no clipboard data (expected) - -Result -Empty clipboard behavior through wl-paste could not be verified directly because wl-paste is unavailable. diff --git a/.sisyphus/evidence/task-1-clipboard-jpeg.txt b/.sisyphus/evidence/task-1-clipboard-jpeg.txt deleted file mode 100644 index 239043287d6e..000000000000 --- a/.sisyphus/evidence/task-1-clipboard-jpeg.txt +++ /dev/null @@ -1,13 +0,0 @@ -Prerequisites -WAYLAND_DISPLAY= -which wl-paste -> not found -which wl-copy -> not found -which convert -> not found - -Scenario 1 (JPEG) command output -zsh:1: command not found: wl-copy -zsh:1: command not found: wl-paste -zsh:1: command not found: wl-paste - -Result -Unable to validate JPEG clipboard MIME listing in this environment because Wayland clipboard tooling is unavailable. diff --git a/.sisyphus/evidence/task-1-clipboard-png.txt b/.sisyphus/evidence/task-1-clipboard-png.txt deleted file mode 100644 index 881932df4257..000000000000 --- a/.sisyphus/evidence/task-1-clipboard-png.txt +++ /dev/null @@ -1,11 +0,0 @@ -Prerequisites -WAYLAND_DISPLAY= -which wl-paste -> not found -which wl-copy -> not found -which convert -> not found - -Scenario 2 (PNG) command output -PNG scenario could not run: wl-copy/wl-paste unavailable in environment. - -Result -Unable to validate PNG backward compatibility in this environment because Wayland clipboard tooling is unavailable. diff --git a/.sisyphus/evidence/task-1-clipboard-text.txt b/.sisyphus/evidence/task-1-clipboard-text.txt deleted file mode 100644 index a9e8ec8cd71c..000000000000 --- a/.sisyphus/evidence/task-1-clipboard-text.txt +++ /dev/null @@ -1,11 +0,0 @@ -Prerequisites -WAYLAND_DISPLAY= -which wl-paste -> not found -which wl-copy -> not found - -Scenario 4 (Text clipboard) command output -zsh:1: command not found: wl-copy -zsh:1: command not found: wl-paste - -Result -Text clipboard type listing could not be verified because Wayland clipboard tooling is unavailable. diff --git a/.sisyphus/notepads/tui-paste-and-drop/learnings.md b/.sisyphus/notepads/tui-paste-and-drop/learnings.md deleted file mode 100644 index 0dc747fb04e1..000000000000 --- a/.sisyphus/notepads/tui-paste-and-drop/learnings.md +++ /dev/null @@ -1,158 +0,0 @@ -## [2026-02-21] Session ses_382961d1effek55DYc2WJeSChP — Atlas initialization - -### Key Architecture Facts - -- **data: vs file:// URLs**: Clipboard binary (Ctrl+V) → `data:` URL (base64 inline). Drag-dropped file paths → `file://` URL (deferred read at submit). NEVER mix these up. -- **pasteImage()**: Lines 696-737 in prompt/index.tsx. Uses `data:${mime};base64,${content}`. Pattern for extmark creation: `input.visualCursor.offset`, `input.extmarks.create({ start, end, virtual: true, styleId: pasteStyleId, typeId: promptPartTypeId })`, `setStore(produce(...))`. -- **onPaste handler**: Lines 914-981. Current bug: line 932 strips quotes/escapes for ONE filepath, then lines 935-961 handle it. Multi-line paste goes to `[Pasted ~N lines]` at line 964. -- **pathToFileURL**: Used in autocomplete.tsx line 2 (imported from "bun"). NOT imported in prompt/index.tsx — needs to be added. -- **Filesystem utilities**: `Filesystem.exists()`, `Filesystem.isDir()`, `Filesystem.mimeType()`, `Filesystem.readArrayBuffer()` — use these, NOT raw fs APIs. -- **clipboard.ts Linux bug**: Lines 58-67 hardcode `wl-paste -t image/png` with NO `$WAYLAND_DISPLAY` check, NO `Bun.which()` guard. Write side (lines 86-96) does check both. Fix: add same guards, add MIME discovery via `wl-paste --list-types` / `xclip -t TARGETS -o`. - -### Existing Single-Path Parse Pattern (must preserve): -```ts -// line 932 — applied per-line in multi-file detection: -const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") -``` - -### File Locations -- `clipboard.ts`: `packages/opencode/src/cli/cmd/tui/util/clipboard.ts` (159 lines) -- `prompt/index.tsx`: `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` (1155 lines) -- `autocomplete.tsx`: `packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx` -- `filesystem.ts`: `packages/opencode/src/util/filesystem.ts` -- Build: `./packages/opencode/script/build.ts --single` from repo root -- Typecheck: `bun run typecheck` from repo root - -## [2026-02-20] Task 1 — Linux clipboard MIME discovery - -- Updated `Clipboard.read()` Linux branch to discover MIME types dynamically before reading image data. -- Detection now mirrors write-side style: Wayland uses `process.env["WAYLAND_DISPLAY"] && Bun.which("wl-paste")`; X11 fallback uses `Bun.which("xclip")` in `else if`. -- Added ordered MIME priority for reads: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/bmp`. -- All Linux subprocesses now use `.nothrow().quiet()` and return actual discovered MIME instead of hardcoded `image/png`. -- `bun run typecheck` passed from repo root after the change. -- Environment lacked `WAYLAND_DISPLAY`, `wl-copy`, `wl-paste`, and `convert`, so runtime clipboard QA evidence files were captured as blocked-by-environment outputs. - -## [2026-02-21] Task 3 — WezTerm Drag-and-Drop Format Research - -### Key Discovery: `quote_dropped_files` Configuration - -WezTerm's drag-and-drop is **configurable** via `quote_dropped_files` setting (since v20220624-141144-bd1b7c5d): -- **Default (Linux/macOS)**: `"SpacesOnly"` — backslash-escape spaces only -- **Windows default**: `"Windows"` — double-quote if spaces -- **Other modes**: `"None"`, `"Posix"`, `"WindowsAlwaysQuoted"` - -### Format Evidence - -**Single file**: `/path/to/file` or `/path/to/my\ file.txt` (backslash-escaped spaces) - -**Multiple files**: **Newline-separated** (one per line) -``` -/home/user/file1.png -/home/user/file2.pdf -/home/user/my\ file.txt -``` - -**NOT file:// URIs** — WezTerm sends bare absolute paths, not `file://` format. - -### Validation Against Existing Code - -Line 932 in `prompt/index.tsx`: -```typescript -const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") -``` - -This **exactly matches** WezTerm's `SpacesOnly` mode: -1. Strips single quotes (if present) -2. Unescapes backslash-spaces to spaces - -### Task 4 Recommendation - -For multi-file parsing, apply the same per-line logic: -```typescript -const lines = pastedContent.split('\n').filter(line => line.trim()) -const filepaths = lines.map(line => - line.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") -) -``` - -This handles: -- ✅ SpacesOnly mode (default) -- ✅ Posix mode (if user configured) -- ✅ Single files (one line) -- ✅ Multiple files (newline-separated) -- ✅ Mixed quoting per line -- ✅ Empty lines (filtered) - -### Sources - -- WezTerm docs: https://wezterm.org/config/lua/config/quote_dropped_files.html -- GitHub #640: Drag & Drop files/folders (closed, implemented) -- WezTerm v20240203-110809-5046fc22 (current environment) -- Evidence file: `.sisyphus/evidence/task-3-wezterm-format.md` - -## [2026-02-21] Task 2 — pasteFile() Implementation Complete - -### Implementation Summary -- **File**: `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` -- **Lines**: 740-793 (54 lines) -- **Import added**: Line 5 — `import { pathToFileURL } from "bun"` - -### Key Implementation Details - -#### Path Normalization (lines 742-745) -```ts -let fp = filepath -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) -``` -- Handles file:// URLs from drag-drop -- Expands tilde (~) to HOME -- Resolves relative paths against working directory - -#### Validation & Metadata (lines 748-754) -- Silent return if file doesn't exist (no error toast) -- Detects directories vs files -- Uses `Filesystem.mimeType()` for MIME detection -- Virtual text: `[File: name]` or `[Dir: name/]` - -#### Extmark Creation (lines 757-769) -- **Identical to pasteImage()** pattern -- Uses `input.visualCursor.offset` for positioning -- Registers with `pasteStyleId` and `promptPartTypeId` -- Stores extmark→part mapping - -#### FilePart Creation (lines 775-785) -- **URL**: `pathToFileURL(fp).href` → `file:///absolute/path` -- **NOT** `data:` URL (key difference from pasteImage) -- **MIME**: `"inode/directory"` for dirs -- **source.path**: Relative path from working directory - -### Pattern Consistency -✅ Matches pasteImage() exactly for: -- Extmark creation structure -- setStore(produce(...)) pattern -- Virtual text positioning -- Part index tracking - -### Differences from pasteImage() -- Uses `file://` URLs instead of `data:` URLs -- Handles directory detection -- Validates file existence -- Normalizes paths (tilde, relative, file://) - -### Ready for Integration -The function is now ready to be called from: -1. `onPaste` handler (for pasted file paths) -2. Drag-drop handler (for dropped files) -3. Task 4 will route image files to pasteImage(), non-images to pasteFile() - -## [2026-02-21] Task 4 — onPaste multi-file path attach - -- Inserted a multi-file detection block in `prompt/index.tsx` immediately after `filepath` normalization and before `isUrl` detection. -- Logic now splits paste payload by newline, trims/filters empty lines, applies existing per-line normalization (`strip quotes`, `unescape \ `), resolves `file://`, `~/`, and relative paths, then checks `Filesystem.exists()` for all lines. -- When all lines resolve to existing paths, paste is intercepted and each path is attached independently: - - raster images (`image/*` except SVG) are loaded as base64 and sent through `pasteImage()` - - everything else routes through `pasteFile()` (including directories and SVG) -- If any line is not a valid existing path, handler falls through to existing behavior unchanged: URL/single-file checks and `[Pasted ~N lines]` summary for multiline text. -- `bun run typecheck` from repo root passed (`Tasks: 12 successful, 12 total`).