From c4afbc4aaed31db9f91a69a593226bcbd360e635 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 18:10:44 -0400 Subject: [PATCH] fix(tui): handle form clipboard shortcut (#43526) --- packages/tui/src/routes/session/form.tsx | 47 +++++++++++++++++++++--- packages/tui/test/cli/tui/form.test.tsx | 38 ++++++++++++++++++- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 0319b5f9d2..5ad49dccc3 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -340,16 +340,36 @@ export function FormPrompt(props: { pick(row.value) } + function pasteCustom(value: string) { + const current = answerField() + if (!current || textual() || !custom() || confirm()) return false + setStore("selected", rows().length) + updateCustom(current, input() + value) + setStore("editing", true) + return true + } + usePaste((event) => { if (keymap.mode.current() !== FORM_MODE) return - const current = answerField() - if (!current || textual() || !custom() || confirm()) return + if (!pasteCustom(stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n"))) return event.preventDefault() - setStore("selected", rows().length) - updateCustom(current, input() + stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")) - setStore("editing", true) }) + function pasteClipboard() { + return clipboard + .read() + .then((content) => { + if (content?.mime !== "text/plain") return + const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n") + if (store.editing || textual()) { + textarea?.insertText(value) + return + } + pasteCustom(value) + }) + .catch(toast.error) + } + function commitInput(text: string) { const current = answerField() if (!current) return false @@ -505,6 +525,23 @@ export function FormPrompt(props: { onMount(() => onCleanup(keymap.mode.push(FORM_MODE))) + Keymap.createLayer(() => ({ + mode: FORM_MODE, + enabled: !confirm() && answerField() !== undefined, + commands: [ + { + id: "prompt.paste", + title: "Paste from clipboard", + group: "Form", + run: (_input, event) => { + event?.preventDefault() + event?.stopPropagation() + return pasteClipboard() + }, + }, + ], + })) + Keymap.createLayer(() => ({ mode: FORM_MODE, priority: 1, diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index 22d68438e8..db2725d352 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -14,7 +14,13 @@ import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" -async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20) { +async function mountForm( + root: string, + width = 80, + fields?: FormWithLocation["fields"], + height = 20, + clipboardText?: string, +) { const state = path.join(root, "state") await mkdir(state, { recursive: true }) @@ -58,7 +64,7 @@ async function mountForm(root: string, width = 80, fields?: FormWithLocation["fi }} clipboard={{ async read() { - return undefined + return clipboardText === undefined ? undefined : { data: clipboardText, mime: "text/plain" } }, write(text) { copied.push(text) @@ -217,6 +223,34 @@ test("pasting on a custom choice opens its editor without submitting", async () } }) +test("clipboard shortcut opens a custom choice editor without submitting", async () => { + await using tmp = await tmpdir() + const prompt = await mountForm( + tmp.path, + 80, + [ + { + key: "target", + type: "string", + options: [{ value: "staging", label: "Staging" }], + custom: true, + }, + ], + 20, + "production\nwest", + ) + try { + prompt.app.mockInput.pressKey("v", { ctrl: true }) + await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production\nwest") + + await prompt.app.waitForFrame((frame) => frame.includes("production")) + expect(prompt.app.captureCharFrame()).not.toContain("Type your own answer") + expect(prompt.replies).toEqual([]) + } finally { + prompt.app.renderer.destroy() + } +}) + test("typing a custom multiselect answer selects it before commit", async () => { await using tmp = await tmpdir() const prompt = await mountForm(tmp.path, 80, [