From 7ac4ed9757fe3afe467cf57640f426846964f018 Mon Sep 17 00:00:00 2001 From: devxpi <277508718+pidevxplay@users.noreply.github.com> Date: Sun, 31 May 2026 19:18:16 +0800 Subject: [PATCH] feat(clipboard): add raw text paste functionality with keyboard shortcut - Add new keybinding `app.clipboard.pasteTextRaw` with Ctrl+Shift+V / Alt+Shift+V defaults - Implement `readTextFromClipboard()` utility supporting macOS, Windows, Linux (Wayland/X11), and Termux - Integrate raw paste handler into CustomEditor and InputController - Text is inserted without formatting collapse to preserve whitespace and newlines --- .../coding-agent/src/config/keybindings.ts | 6 +++ .../src/modes/components/custom-editor.ts | 10 +++++ .../src/modes/controllers/input-controller.ts | 20 +++++++++- packages/coding-agent/src/utils/clipboard.ts | 37 +++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index b8a89c908..1c379b972 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -32,6 +32,7 @@ interface AppKeybindings { "app.message.followUp": true; "app.message.dequeue": true; "app.clipboard.pasteImage": true; + "app.clipboard.pasteTextRaw": true; "app.clipboard.copyLine": true; "app.clipboard.copyPrompt": true; "app.session.new": true; @@ -122,6 +123,10 @@ export const KEYBINDINGS = { defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", description: "Paste image from clipboard", }, + "app.clipboard.pasteTextRaw": { + defaultKeys: ["ctrl+shift+v", "alt+shift+v"], + description: "Paste text from clipboard as raw text (no collapse)", + }, "app.clipboard.copyLine": { defaultKeys: "alt+shift+l", description: "Copy current line", @@ -214,6 +219,7 @@ const KEYBINDING_NAME_MIGRATIONS = { followUp: "app.message.followUp", dequeue: "app.message.dequeue", pasteImage: "app.clipboard.pasteImage", + pasteTextRaw: "app.clipboard.pasteTextRaw", copyLine: "app.clipboard.copyLine", copyPrompt: "app.clipboard.copyPrompt", newSession: "app.session.new", diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index bb97c1598..652fdf0a2 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -19,6 +19,7 @@ type ConfigurableEditorAction = Extract< | "app.history.search" | "app.message.dequeue" | "app.clipboard.pasteImage" + | "app.clipboard.pasteTextRaw" | "app.clipboard.copyPrompt" >; @@ -38,6 +39,7 @@ const DEFAULT_ACTION_KEYS: Record = { "app.history.search": ["ctrl+r"], "app.message.dequeue": ["alt+up"], "app.clipboard.pasteImage": ["ctrl+v"], + "app.clipboard.pasteTextRaw": ["ctrl+shift+v", "alt+shift+v"], "app.clipboard.copyPrompt": ["alt+shift+c"], }; @@ -66,6 +68,8 @@ export class CustomEditor extends Editor { onCopyPrompt?: () => void; /** Called when the configured image-paste shortcut is pressed. */ onPasteImage?: () => Promise; + /** Called when the configured raw text-paste shortcut is pressed. */ + onPasteTextRaw?: () => void; /** Called when the configured dequeue shortcut is pressed. */ onDequeue?: () => void; /** Called when Caps Lock is pressed. */ @@ -125,6 +129,12 @@ export class CustomEditor extends Editor { return; } + // Intercept configured raw text paste (fires and handles result) + if (this.#matchesAction(data, "app.clipboard.pasteTextRaw") && this.onPasteTextRaw) { + this.onPasteTextRaw(); + return; + } + // Intercept configured external editor shortcut if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) { this.onExternalEditor(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 5bca6d000..c38909afe 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -15,7 +15,7 @@ import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registr import { isTinyTitleLocalModelKey } from "../../tiny/models"; import { tinyTitleClient } from "../../tiny/title-client"; import type { TinyTitleProgressEvent } from "../../tiny/title-protocol"; -import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard"; +import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { ensureSupportedImageInput } from "../../utils/image-loading"; import { resizeImage } from "../../utils/image-resize"; @@ -188,6 +188,11 @@ export class InputController { this.ctx.keybindings.getKeys("app.clipboard.pasteImage"), ); this.ctx.editor.onPasteImage = () => this.handleImagePaste(); + this.ctx.editor.setActionKeys( + "app.clipboard.pasteTextRaw", + this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"), + ); + this.ctx.editor.onPasteTextRaw = () => void this.handleClipboardTextRawPaste(); this.ctx.editor.setActionKeys( "app.clipboard.copyPrompt", this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"), @@ -705,6 +710,19 @@ export class InputController { } } + async handleClipboardTextRawPaste(): Promise { + try { + const text = await readTextFromClipboard(); + if (text) { + this.ctx.editor.insertText(text); + this.ctx.ui.requestRender(); + this.ctx.showStatus("No text in clipboard to paste raw"); + } + } catch { + this.ctx.showStatus("Failed to paste raw text from clipboard"); + } + } + createAutocompleteProvider(commands: SlashCommand[], basePath: string): AutocompleteProvider { return createPromptActionAutocompleteProvider({ commands, diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 22438ee0a..e2a3f2086 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -154,3 +154,40 @@ export async function readImageFromClipboard(): Promise { return (await native.readImageFromClipboard()) ?? null; } + +/** + * Read plain text from the system clipboard. + */ +export async function readTextFromClipboard(): Promise { + try { + const p = process.platform; + if (p === "darwin") { + return execSync("pbpaste", { encoding: "utf8", timeout: 2000 }).toString(); + } + if (p === "win32") { + return execSync('powershell.exe -NoProfile -Command "Get-Clipboard"', { + encoding: "utf8", + timeout: 2000, + }).toString(); + } + if (process.env.TERMUX_VERSION) { + return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString(); + } + const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY); + const hasX11Display = Boolean(process.env.DISPLAY); + if (hasWaylandDisplay) { + try { + return execSync("wl-paste --type text/plain --no-newline", { encoding: "utf8", timeout: 2000 }).toString(); + } catch { + if (hasX11Display) { + return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString(); + } + } + } else if (hasX11Display) { + return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString(); + } + } catch (error) { + logger.warn("clipboard: failed to read clipboard text", { error: String(error) }); + } + return ""; +}