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
6 changes: 4 additions & 2 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -278,7 +280,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ClipboardProvider value={input.clipboard}>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<KVProvider>
Expand Down
1 change: 1 addition & 0 deletions packages/tui/src/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
30 changes: 28 additions & 2 deletions packages/tui/src/ui/dialog-prompt.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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<TextareaRenderable>()
let textarea: TextareaRenderable
Expand All @@ -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<Renderable, KeyEvent>) {
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,
Expand All @@ -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(() => {
Expand Down
105 changes: 105 additions & 0 deletions packages/tui/test/dialog-prompt-paste.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createTestRenderer>>, 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<void>((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)
Loading