From 5294c452c122d352ed983663e9532dda02a8f2b9 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Sat, 10 Oct 2026 21:24:25 -0500 Subject: [PATCH] feat(tui): paste without a placeholder on leader v or leader then paste A long paste always became a "[Pasted ~N lines]" placeholder unless summarizing was switched off for every paste, so pasting text to keep editing it inline meant toggling the setting twice. input_paste_raw (default v, command prompt.paste.raw) pastes the clipboard as typed. It reads the clipboard the way input_paste does, on the machine opencode runs on, which is the wrong clipboard over ssh or a remote tmux attach. For that case a terminal paste that arrives while the leader is pending also goes in as typed, and clears the leader: the terminal's own paste carries the clipboard of the machine the user is at. An image is still attached as usual. --- packages/tui/src/component/prompt/index.tsx | 50 +++++++++++++++++-- packages/tui/src/config/keybind.ts | 2 + packages/tui/test/keymap.test.tsx | 55 +++++++++++++++++++++ packages/web/src/content/docs/keybinds.mdx | 12 +++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index c48c751739ce..03fe6b50cfd4 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -75,6 +75,11 @@ export type PromptProps = { } } +// Replace CRLF first, then any remaining CR. +function normalizeLineEndings(text: string) { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + function pastedFilepath(value: string, platform: string) { const raw = value.replace(/^['"]+|['"]+$/g, "") if (raw.startsWith("file://")) { @@ -389,6 +394,23 @@ export function Prompt(props: PromptProps) { } }, }, + { + title: "Paste without a placeholder", + name: "prompt.paste.raw", + category: "Prompt", + run: async () => { + const content = await clipboard.read?.() + if (content?.mime.startsWith("image/")) { + await pasteAttachment({ + filename: "clipboard", + mime: content.mime, + content: content.data, + }) + return + } + if (content?.mime === "text/plain") insertPastedText(normalizeLineEndings(content.data)) + }, + }, { title: "Interrupt session", name: "session.interrupt", @@ -805,6 +827,14 @@ export function Prompt(props: PromptProps) { } }) + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && !props.disabled, + bindings: tuiConfig.keybinds.get("prompt.paste.raw"), + } + }) + useBindings(() => { return { target: inputTarget, @@ -1181,7 +1211,7 @@ export function Prompt(props: PromptProps) { } async function pasteInputText(text: string) { - const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const normalizedText = normalizeLineEndings(text) const pastedContent = normalizedText.trim() const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform) const isUrl = /^(https?):\/\//.test(filepath) @@ -1212,7 +1242,11 @@ export function Prompt(props: PromptProps) { return } - input.insertText(normalizedText) + insertPastedText(normalizedText) + } + + function insertPastedText(text: string) { + input.insertText(text) setTimeout(() => { if (!input || input.isDestroyed) return @@ -1401,9 +1435,13 @@ export function Prompt(props: PromptProps) { // Normalize line endings at the boundary // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste - // Replace CRLF first, then any remaining CR - const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const normalizedText = normalizeLineEndings(decodePasteBytes(event.bytes)) const pastedContent = normalizedText.trim() + // A terminal paste right after the leader key goes in as typed, the way the raw paste key + // does with the clipboard: over ssh or tmux, the terminal's paste is the one that holds the + // clipboard of the machine the user is sitting at. + const raw = leader() + if (raw) keymap.clearPendingSequence() // Windows Terminal <1.25 can surface image-only clipboard as an // empty bracketed paste. Windows Terminal 1.25+ does not. @@ -1416,6 +1454,10 @@ export function Prompt(props: PromptProps) { // default paste unless we suppress it first and handle insertion ourselves. event.preventDefault() + if (raw) { + insertPastedText(normalizedText) + return + } await pasteInputText(normalizedText) }} ref={(r: TextareaRenderable) => { diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..ddbddf02b929 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -160,6 +160,7 @@ export const Definitions = { input_clear: keybind("ctrl+c", "Clear input field"), input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), + input_paste_raw: keybind("v", "Paste from clipboard as typed, without a placeholder"), input_submit: keybind("return", "Submit input"), input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), @@ -363,6 +364,7 @@ export const CommandMap = { workspace_set: "workspace.set", input_clear: "prompt.clear", input_paste: "prompt.paste", + input_paste_raw: "prompt.paste.raw", input_submit: "input.submit", input_newline: "input.newline", input_move_left: "input.move.left", diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index b0eed3081248..661bcc2c8845 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -139,3 +139,58 @@ test("mode-less bindings stay active when opencode mode changes", async () => { app.renderer.destroy() } }) + +test("leader then v runs the raw paste, and a terminal paste after the leader finds it still pending", async () => { + const ran: string[] = [] + const pasted: { text: string; leader: boolean }[] = [] + let leaderActive = () => false + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const config = createResolvedKeymapConfig() + const offKeymap = registerOpencodeKeymap(keymap, renderer, config) + const offLayer = keymap.registerLayer({ + commands: [{ name: "prompt.paste.raw", run: () => void ran.push("prompt.paste.raw") }], + bindings: config.keybinds.get("prompt.paste.raw"), + }) + leaderActive = () => keymap.getPendingSequence()[0]?.tokenName === "leader" + onCleanup(() => { + offLayer() + offKeymap() + }) + + return ( + +