diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 191d0a936a..69e0c7c757 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -1,7 +1,7 @@ import { createStore } from "solid-js/store" import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" -import { useRenderer } from "@opentui/solid" -import type { TextareaRenderable } from "@opentui/core" +import { usePaste, useRenderer } from "@opentui/solid" +import { decodePasteBytes, type TextareaRenderable } from "@opentui/core" import { selectedForeground, tint, useTheme } from "../../context/theme" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" @@ -130,6 +130,19 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st onCleanup(popMode) }) + usePaste((event) => { + if (store.editing) return + + // Question options do not own a text input. Capture bracketed paste at the + // renderer level and use it to start editing the custom answer. + event.preventDefault() + if (confirm() || !custom() || !decodePasteBytes(event.bytes).trim()) return + + setStore("selected", options().length) + setStore("editing", true) + queueMicrotask(() => textarea?.handlePaste(event)) + }) + useBindings(() => ({ mode: QUESTION_MODE, enabled: store.editing && !confirm(), diff --git a/packages/tui/test/cli/tui/question-paste.test.tsx b/packages/tui/test/cli/tui/question-paste.test.tsx new file mode 100644 index 0000000000..7c24b860a6 --- /dev/null +++ b/packages/tui/test/cli/tui/question-paste.test.tsx @@ -0,0 +1,82 @@ +/** @jsxImportSource @opentui/solid */ +import { TextareaRenderable } from "@opentui/core" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { onCleanup } from "solid-js" +import { tmpdir } from "../../fixture/fixture" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { TuiConfigProvider } from "../../../src/config" +import { KVProvider } from "../../../src/context/kv" +import { SDKProvider } from "../../../src/context/sdk" +import { ThemeProvider } from "../../../src/context/theme" +import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap" +import { QuestionPrompt } from "../../../src/routes/session/question" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +test("pasting while selecting starts a custom answer", async () => { + await using tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + + const request = { + id: "question-1", + sessionID: "session-1", + questions: [ + { + header: "Approach", + question: "How should this be implemented?", + options: [{ label: "Suggested", description: "Use the suggested approach" }], + }, + ], + } satisfies QuestionRequest + + function Harness() { + const renderer = useRenderer() + const config = createTuiResolvedConfig() + const keymap = createDefaultOpenTuiKeymap(renderer) + const off = registerOpencodeKeymap(keymap, renderer, config) + onCleanup(off) + + return ( + + () => {} }}> + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { width: 80, height: 20 }) + try { + await wait(() => app.captureCharFrame().includes("How should this be implemented?")) + await app.mockInput.pasteBracketedText("Use the existing state machine") + await wait(() => app.renderer.currentFocusedEditor instanceof TextareaRenderable) + + const textarea = app.renderer.currentFocusedEditor + if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused custom answer textarea") + expect(textarea.plainText).toBe("Use the existing state machine") + } finally { + app.renderer.destroy() + } +})