diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 82b38058..0010ff44 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed `Ctrl+V` to paste clipboard text when the pasteboard does not contain an image. - Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. ## [0.80.6] - 2026-07-09 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index ac176dfc..b9a7911a 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -162,7 +162,7 @@ The editor can be temporarily replaced by other UI, like built-in `/settings` or | Path completion | Tab to complete paths | | Multi-line | Shift+Enter (or Ctrl+Enter on Windows Terminal) | | External editor | Ctrl+G opens `externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere | -| Images | Ctrl+V to paste (Alt+V on Windows), or drag onto terminal | +| Clipboard | Ctrl+V to paste an image or text (Alt+V on Windows), or drag images onto terminal | | Bash commands | `!command` runs and sends output to LLM, `!!command` runs without sending | Standard editing keybindings for delete word, undo, etc. See [docs/keybindings.md](docs/keybindings.md). diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 59026738..89314336 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -113,7 +113,7 @@ pi @README.md "Summarize this" pi @src/app.ts @src/app.test.ts "Review these together" ``` -Images can be pasted with Ctrl+V (Alt+V on Windows) or dragged into supported terminals. +Images or text can be pasted with Ctrl+V (Alt+V on Windows); images can also be dragged into supported terminals. ### Run shell commands diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 0e0bd443..ec5b135d 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -110,7 +110,7 @@ export const KEYBINDINGS = { }, "app.clipboard.pasteImage": { defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", - description: "Paste image from clipboard", + description: "Paste image from clipboard (text fallback)", }, "app.session.new": { defaultKeys: [], description: "Start a new session" }, "app.session.tree": { defaultKeys: [], description: "Open session tree" }, diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index a9d25622..92c20b5c 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -33,7 +33,7 @@ export class CustomEditor extends Editor { return; } - // Check for paste image keybinding + // Check for clipboard paste keybinding if (this.keybindings.matches(data, "app.clipboard.pasteImage")) { this.onPasteImage?.(); return; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1d6864e1..b745c454 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -95,7 +95,7 @@ import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; -import { copyToClipboard } from "../../utils/clipboard.ts"; +import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; import { parseGitUrl } from "../../utils/git.ts"; import { getCwdRelativePath } from "../../utils/paths.ts"; @@ -770,7 +770,7 @@ export class InteractiveMode { rawKeyHint("!!", "to run bash (no context)"), hint("app.message.followUp", "to queue follow-up"), hint("app.message.dequeue", "to edit all queued messages"), - hint("app.clipboard.pasteImage", "to paste image"), + hint("app.clipboard.pasteImage", "to paste image (with text fallback)"), rawKeyHint("drop files", "to attach"), ].join("\n"); const compactInstructions = [ @@ -2599,29 +2599,33 @@ export class InteractiveMode { } }; - // Handle clipboard image paste (triggered on Ctrl+V) + // Handle clipboard paste (triggered on Ctrl+V). Images are attached by path; + // otherwise, paste plain text from the system clipboard. this.defaultEditor.onPasteImage = () => { - this.handleClipboardImagePaste(); + void this.handleClipboardPaste(); }; } - private async handleClipboardImagePaste(): Promise { + private async handleClipboardPaste(): Promise { try { const image = await readClipboardImage(); - if (!image) { + if (image) { + const tmpDir = os.tmpdir(); + const ext = extensionForImageMimeType(image.mimeType) ?? "png"; + const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`; + const filePath = path.join(tmpDir, fileName); + fs.writeFileSync(filePath, Buffer.from(image.bytes)); + + this.editor.insertTextAtCursor?.(filePath); + this.ui.requestRender(); return; } - // Write to temp file - const tmpDir = os.tmpdir(); - const ext = extensionForImageMimeType(image.mimeType) ?? "png"; - const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`; - const filePath = path.join(tmpDir, fileName); - fs.writeFileSync(filePath, Buffer.from(image.bytes)); - - // Insert file path directly - this.editor.insertTextAtCursor?.(filePath); - this.ui.requestRender(); + const text = await readClipboardText(); + if (text) { + this.editor.insertTextAtCursor?.(text); + this.ui.requestRender(); + } } catch { // Silently ignore clipboard errors (may not have permission, etc.) } @@ -5795,7 +5799,7 @@ export class InteractiveMode { | \`${copyMessage}\` | Copy last assistant message | | \`${followUp}\` | Queue follow-up message | | \`${dequeue}\` | Restore queued messages | -| \`${pasteImage}\` | Paste image from clipboard | +| \`${pasteImage}\` | Paste image or text from clipboard | | \`/\` | Slash commands | | \`!\` | Run bash command | | \`!!\` | Run bash command (excluded from context) | diff --git a/packages/coding-agent/src/utils/clipboard-native.ts b/packages/coding-agent/src/utils/clipboard-native.ts index f8aeb547..7ea921ad 100644 --- a/packages/coding-agent/src/utils/clipboard-native.ts +++ b/packages/coding-agent/src/utils/clipboard-native.ts @@ -3,6 +3,7 @@ import { dirname, join } from "path"; import { pathToFileURL } from "url"; export type ClipboardModule = { + getText: () => Promise; setText: (text: string) => Promise; hasImage: () => boolean; getImageBinary: () => Promise>; diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 26e1d018..9bb10d5c 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -32,6 +32,20 @@ function emitOsc52(text: string): boolean { return true; } +/** Read plain text from the system clipboard, if native clipboard access is available. */ +export async function readClipboardText(): Promise { + if (!clipboard) { + return null; + } + + try { + const text = await clipboard.getText(); + return text || null; + } catch { + return null; + } +} + export async function copyToClipboard(text: string): Promise { let copied = false; diff --git a/packages/coding-agent/test/clipboard-native.test.ts b/packages/coding-agent/test/clipboard-native.test.ts index a9c0c201..d3ec209d 100644 --- a/packages/coding-agent/test/clipboard-native.test.ts +++ b/packages/coding-agent/test/clipboard-native.test.ts @@ -4,6 +4,7 @@ import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboar type ClipboardRequire = (id: string) => unknown; const fakeClipboard: ClipboardModule = { + getText: async () => "", setText: async () => {}, hasImage: () => true, getImageBinary: async () => [1, 2, 3], diff --git a/packages/coding-agent/test/clipboard.test.ts b/packages/coding-agent/test/clipboard.test.ts index 6ea3a685..c3089a39 100644 --- a/packages/coding-agent/test/clipboard.test.ts +++ b/packages/coding-agent/test/clipboard.test.ts @@ -1,11 +1,12 @@ import { execSync, spawn } from "child_process"; import { platform } from "os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { copyToClipboard } from "../src/utils/clipboard.ts"; +import { copyToClipboard, readClipboardText } from "../src/utils/clipboard.ts"; const mocks = vi.hoisted(() => { return { clipboard: { + getText: vi.fn<() => Promise>(), setText: vi.fn<(text: string) => Promise>(), }, execSync: vi.fn(), @@ -59,6 +60,7 @@ beforeEach(() => { vi.stubEnv("MOSH_CONNECTION", ""); stdoutWrites = []; nativeResolved = false; + mocks.clipboard.getText.mockReset(); mocks.clipboard.setText.mockReset(); mocks.execSync.mockReset(); mocks.spawn.mockReset(); @@ -66,6 +68,7 @@ beforeEach(() => { mocks.isWaylandSession.mockReset(); mockedPlatform.mockReturnValue("darwin"); mocks.isWaylandSession.mockReturnValue(false); + mocks.clipboard.getText.mockResolvedValue(""); mocks.clipboard.setText.mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 1)); nativeResolved = true; @@ -86,6 +89,21 @@ afterEach(() => { vi.unstubAllEnvs(); }); +describe("readClipboardText", () => { + test("returns native clipboard text", async () => { + mocks.clipboard.getText.mockResolvedValue("clipboard text"); + + await expect(readClipboardText()).resolves.toBe("clipboard text"); + }); + + test("returns null for empty or unavailable clipboard text", async () => { + await expect(readClipboardText()).resolves.toBeNull(); + + mocks.clipboard.getText.mockRejectedValue(new Error("clipboard unavailable")); + await expect(readClipboardText()).resolves.toBeNull(); + }); +}); + describe("copyToClipboard", () => { test("local native success skips OSC 52 and shell fallbacks", async () => { await copyToClipboard("hello");