Skip to content
Open
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
147 changes: 87 additions & 60 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,12 @@ export function Prompt(props: PromptProps) {
{
title: "Paste",
value: "prompt.paste",
keybind: "input_paste",
// Deliberately no `keybind`: the command dialog's global keypress
// listener would intercept input_paste (ctrl+v) first and
// preventDefault, which skips the textarea's onKeyDown — where the
// actual text/image paste runs. Keep ctrl+v flowing to the textarea;
// this command stays reachable only via command.trigger() (e.g. the
// empty-bracketed-paste image fallback in onPaste).
category: "Prompt",
hidden: true,
onSelect: async () => {
Expand Down Expand Up @@ -1136,6 +1141,73 @@ export function Prompt(props: PromptProps) {
return
}

// Shared text-paste pipeline used by both bracketed paste (onPaste) and
// terminals that forward Ctrl+V as a keydown (e.g. Windows Terminal 1.25+
// with the kitty keyboard protocol active). Keeps paste behavior identical
// regardless of which signal the terminal delivers it through.
async function handlePastedText(normalizedText: string) {
const pastedContent = normalizedText.trim()

const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (process.platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
})
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/") || mime === "application/pdf") {
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteAttachment({
filename,
filepath,
mime,
content,
})
return
}
}
} catch {}
}

const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}

input.insertText(normalizedText)

// Force layout update and render for the pasted content
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
}

const highlight = createMemo(() => {
if (keybind.leader) return theme.border
if (store.mode === "shell") return theme.primary
Expand Down Expand Up @@ -1395,7 +1467,19 @@ export function Prompt(props: PromptProps) {
})
return
}
// If no image, let the default paste behavior continue
// Terminals that forward Ctrl+V to the app instead of pasting
// themselves (e.g. Windows Terminal 1.25+ with the kitty
// keyboard protocol active) never emit a bracketed paste, so a
// text clipboard would otherwise be dropped entirely. Insert it
// through the same pipeline as bracketed paste. Terminals that
// paste natively never deliver the Ctrl+V keydown, so this
// cannot double-insert.
if (content?.mime.startsWith("text/") && content.data.length > 0) {
e.preventDefault()
const normalizedText = content.data.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
await handlePastedText(normalizedText)
return
}
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
Expand Down Expand Up @@ -1482,64 +1566,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()

const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (process.platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
})
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/") || mime === "application/pdf") {
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteAttachment({
filename,
filepath,
mime,
content,
})
return
}
}
} catch {}
}

const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}

input.insertText(normalizedText)

// Force layout update and render for the pasted content
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
await handlePastedText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { DialogHeader, useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createMemo, onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Spans, type GlowSpan } from "@tui/ui/glow"
import { useKeybind } from "@tui/context/keybind"
import * as Clipboard from "@tui/util/clipboard"

export type DialogPromptProps = {
title: string
Expand All @@ -22,15 +24,22 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
const keybind = useKeybind()
let textarea: TextareaRenderable

useKeyboard((evt) => {
useKeyboard(async (evt) => {
if (props.busy) {
if (evt.name === "escape") return
evt.preventDefault()
evt.stopPropagation()
return
}
if (keybind.match("input_paste", evt)) {
evt.preventDefault()
evt.stopPropagation()
await Clipboard.pasteText(textarea)
return
}
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Locale } from "@/util/locale"
import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../context/tui-config"
import { sinkColor, Spans, type GlowSpan } from "@tui/ui/glow"
import * as Clipboard from "@tui/util/clipboard"

export interface DialogSelectProps<T> {
title: string
Expand Down Expand Up @@ -198,9 +199,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}

const keybind = useKeybind()
useKeyboard((evt) => {
useKeyboard(async (evt) => {
setStore("input", "keyboard")

if (keybind.match("input_paste", evt)) {
evt.preventDefault()
evt.stopPropagation()
await Clipboard.pasteText(input)
return
}
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10)
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ export async function read(): Promise<Content | undefined> {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}

// Text read via PowerShell. The compiled single-file exe bundles clipboardy's
// Windows implementation, which spawns a helper binary (clipboard_x86_64.exe)
// from a virtual in-bundle path that cannot be executed, so every clipboard
// read fails at runtime there. PowerShell (the same mechanism as the image
// probe above and the write path below) works identically in dev and in the
// compiled exe. `[Console]::Write` avoids PowerShell's output formatting, so
// the returned text is byte-exact (no appended newline).
const textScript =
"Add-Type -AssemblyName System.Windows.Forms; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write([System.Windows.Forms.Clipboard]::GetText())"
const textResult = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", textScript], {
nothrow: true,
})
if (textResult.text) {
return { data: textResult.text, mime: "text/plain" }
}
}

if (os === "linux") {
Expand All @@ -110,6 +126,16 @@ export async function read(): Promise<Content | undefined> {
}
}

export async function pasteText(
input: { insertText(text: string): void },
readClipboard: () => Promise<Content | undefined> = read,
) {
const content = await readClipboard()
if (!content?.mime.startsWith("text/") || content.data.length === 0) return false
input.insertText(content.data.replace(/\r\n/g, "\n").replace(/\r/g, "\n"))
return true
}

const getCopyMethod = lazy(async () => {
const os = platform()
const which = await getWhich()
Expand Down
25 changes: 25 additions & 0 deletions packages/opencode/test/cli/cmd/tui/clipboard-paste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test"
import * as Clipboard from "../../../../src/cli/cmd/tui/util/clipboard"

describe("clipboard text insertion", () => {
test("normalizes and inserts clipboard text", async () => {
const inserted: string[] = []
const result = await Clipboard.pasteText(
{ insertText: (text) => inserted.push(text) },
async () => ({ data: "first\r\nsecond\rthird", mime: "text/plain" }),
)

expect(result).toBe(true)
expect(inserted).toEqual(["first\nsecond\nthird"])
})

test("ignores non-text and empty clipboards", async () => {
const inserted: string[] = []
const input = { insertText: (text: string) => inserted.push(text) }

expect(await Clipboard.pasteText(input, async () => ({ data: "image", mime: "image/png" }))).toBe(false)
expect(await Clipboard.pasteText(input, async () => ({ data: "", mime: "text/plain" }))).toBe(false)
expect(await Clipboard.pasteText(input, async () => undefined)).toBe(false)
expect(inserted).toEqual([])
})
})
37 changes: 37 additions & 0 deletions packages/opencode/test/cli/cmd/tui/dialog-paste-ctrlv.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import type { KeyEvent, TextareaRenderable } from "@opentui/core"
import { testRender, useKeyboard } from "@opentui/solid"
import { Keybind } from "../../../../src/util/keybind"
import * as Clipboard from "../../../../src/cli/cmd/tui/util/clipboard"

test("a dialog keyboard listener pastes into its focused input", async () => {
let input: TextareaRenderable | undefined
const handle = await testRender(
() => {
useKeyboard(async (evt: KeyEvent) => {
if (!Keybind.parse("ctrl+v").some((bind) => Keybind.match(bind, Keybind.fromParsedKey(evt)))) return
evt.preventDefault()
evt.stopPropagation()
await Clipboard.pasteText(input!, async () => ({ data: "api-key-from-clipboard", mime: "text/plain" }))
})
return <textarea ref={(value: TextareaRenderable) => (input = value)} />
},
{ kittyKeyboard: true, width: 60, height: 10 },
)

try {
input?.focus()
handle.mockInput.pressKey("v", { ctrl: true })
await handle.renderOnce()

const start = Date.now()
while (input?.plainText !== "api-key-from-clipboard") {
if (Date.now() - start > 2000) throw new Error("timed out waiting for paste")
await Bun.sleep(10)
}
expect(input.plainText).toBe("api-key-from-clipboard")
} finally {
handle.renderer.destroy()
}
})
Loading