diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..30854881e8a7 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -5,7 +5,7 @@ import { Deferred, Effect } from "effect" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { ClipboardProvider, useClipboard } from "./context/clipboard" +import { ClipboardProvider, useClipboard, type ClipboardService } from "./context/clipboard" import { ExitProvider, useExit } from "./context/exit" import { EpilogueProvider } from "./context/epilogue" import * as Selection from "./util/selection" @@ -148,6 +148,8 @@ export type TuiInput = { fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource + // Lets tests inject a deterministic clipboard for the dialog paste command. + clipboard?: ClipboardService pluginHost: TuiPluginHost } @@ -278,7 +280,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT), }} > - + diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..11b2e75feb23 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -207,6 +207,7 @@ export const Definitions = { "dialog.select.end": keybind("end", "Move to last dialog item"), "dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), + "dialog.prompt.paste": keybind({ key: "ctrl+v", preventDefault: false }, "Paste into dialog prompt"), "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), "dialog.move_session.new": keybind("ctrl+m", "New project copy"), "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index f518fb2950b7..97497ff815fc 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -1,8 +1,10 @@ -import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { TextareaRenderable, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core" +import type { CommandContext } from "@opentui/keymap" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js" import { Spinner } from "../component/spinner" +import { useClipboard } from "../context/clipboard" import { useTuiConfig } from "../config" import { useBindings, useCommandShortcut } from "../keymap" @@ -21,6 +23,7 @@ export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() + const clipboard = useClipboard() const submitShortcut = useCommandShortcut("dialog.prompt.submit") const [textareaTarget, setTextareaTarget] = createSignal() let textarea: TextareaRenderable @@ -30,6 +33,22 @@ export function DialogPrompt(props: DialogPromptProps) { props.onConfirm?.(textarea.plainText) } + // Many Windows terminals deliver ctrl+v as a key event instead of a bracketed + // paste, and the clipboard-reading prompt.paste command only targets the main + // session prompt — so dialog fields (API keys in /connect, provider ids) had + // no way to paste at all. Bracketed paste still goes through the textarea's + // built-in default handler. Newlines are stripped like the single-line + // InputRenderable paste: every dialog prompt is a single-line field (return + // submits), and onConfirm consumers store the value verbatim. + async function paste(ctx: CommandContext) { + ctx.event.preventDefault() + ctx.event.stopPropagation() + const content = await clipboard.read?.() + if (content?.mime !== "text/plain") return + if (!textarea || textarea.isDestroyed) return + textarea.insertText(content.data.replace(/[\n\r]/g, "")) + } + useBindings(() => ({ target: textareaTarget, enabled: textareaTarget() !== undefined && !props.busy, @@ -42,8 +61,15 @@ export function DialogPrompt(props: DialogPromptProps) { category: "Dialog", run: confirm, }, + { + name: "dialog.prompt.paste", + title: "Paste into dialog prompt", + category: "Dialog", + hidden: true, + run: paste, + }, ], - bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]), + bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit", "dialog.prompt.paste"]), })) onMount(() => { diff --git a/packages/tui/test/dialog-prompt-paste.test.tsx b/packages/tui/test/dialog-prompt-paste.test.tsx new file mode 100644 index 000000000000..66f7be913d24 --- /dev/null +++ b/packages/tui/test/dialog-prompt-paste.test.tsx @@ -0,0 +1,105 @@ +import { expect, mock, test } from "bun:test" +import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk" + +async function readBody(input: RequestInfo | URL, init?: RequestInit) { + if (input instanceof Request) return input.clone().json().catch(() => undefined) + if (typeof init?.body === "string") return JSON.parse(init.body) + return undefined +} + +async function waitFrame(setup: Awaited>, fn: (frame: string) => boolean) { + const start = Date.now() + while (true) { + await setup.renderOnce() + const frame = setup.captureCharFrame() + if (fn(frame)) return frame + if (Date.now() - start > 15_000) throw new Error("timed out waiting for frame:\n" + frame) + await Bun.sleep(25) + } +} + +// The Windows path: many terminals there deliver ctrl+v as a key event instead +// of a bracketed paste, so dialog prompts must read the clipboard themselves — +// the clipboard-reading prompt.paste command only targets the main session +// prompt. Walks /connect -> Other -> API key, pastes the key with ctrl+v, and +// asserts the stored credential (newlines from the Windows clipboard stripped, +// since ApiMethod stores the value verbatim). +test("ctrl+v pastes the clipboard into the connect API key prompt", async () => { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + const base = createFetch(undefined, events) + const auth: { providerID: string; body: unknown }[] = [] + + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + const method = (input instanceof Request ? input.method : (init?.method ?? "GET")).toUpperCase() + if (method === "PUT" && url.pathname.startsWith("/auth/")) { + auth.push({ providerID: decodeURIComponent(url.pathname.slice("/auth/".length)), body: await readBody(input, init) }) + return json({}) + } + if (method === "POST" && url.pathname === "/instance/dispose") return json({}) + return base.fetch(input, init) + }) as typeof globalThis.fetch + + let api: TuiPluginApi | undefined + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch, + events: events.source, + clipboard: { read: async () => ({ data: "sk-pasted\r\n", mime: "text/plain" }) }, + args: {}, + pluginHost: { + async start(input) { + api = input.api + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + + try { + api!.keymap.dispatchCommand("provider.connect") + await waitFrame(setup, (f) => f.includes("Connect a provider")) + setup.mockInput.pressEnter() // "Other" is the only option with no providers registered + + await waitFrame(setup, (f) => f.includes("Provider id")) + await setup.mockInput.typeText("credx") + setup.mockInput.pressEnter() + + await waitFrame(setup, (f) => f.includes("API key")) + setup.mockInput.pressKey("v", { ctrl: true }) + await waitFrame(setup, (f) => f.includes("sk-pasted")) + setup.mockInput.pressEnter() + + const start = Date.now() + while (auth.length === 0) { + await setup.renderOnce() + if (Date.now() - start > 15_000) throw new Error("timed out waiting for the credential write") + await Bun.sleep(25) + } + expect(auth).toEqual([{ providerID: "credx", body: { type: "api", key: "sk-pasted" } }]) + } finally { + api?.keymap.dispatchCommand("app.exit") + await task + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}, 60_000)