fix(coding-agent): fall back to text clipboard paste
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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
|
## [0.80.6] - 2026-07-09
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ The editor can be temporarily replaced by other UI, like built-in `/settings` or
|
|||||||
| Path completion | Tab to complete paths |
|
| Path completion | Tab to complete paths |
|
||||||
| Multi-line | Shift+Enter (or Ctrl+Enter on Windows Terminal) |
|
| Multi-line | Shift+Enter (or Ctrl+Enter on Windows Terminal) |
|
||||||
| External editor | Ctrl+G opens `externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere |
|
| 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 |
|
| 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).
|
Standard editing keybindings for delete word, undo, etc. See [docs/keybindings.md](docs/keybindings.md).
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ pi @README.md "Summarize this"
|
|||||||
pi @src/app.ts @src/app.test.ts "Review these together"
|
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
|
### Run shell commands
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const KEYBINDINGS = {
|
|||||||
},
|
},
|
||||||
"app.clipboard.pasteImage": {
|
"app.clipboard.pasteImage": {
|
||||||
defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
|
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.new": { defaultKeys: [], description: "Start a new session" },
|
||||||
"app.session.tree": { defaultKeys: [], description: "Open session tree" },
|
"app.session.tree": { defaultKeys: [], description: "Open session tree" },
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class CustomEditor extends Editor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for paste image keybinding
|
// Check for clipboard paste keybinding
|
||||||
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
|
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
|
||||||
this.onPasteImage?.();
|
this.onPasteImage?.();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
|||||||
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||||
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.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 { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||||
import { parseGitUrl } from "../../utils/git.ts";
|
import { parseGitUrl } from "../../utils/git.ts";
|
||||||
import { getCwdRelativePath } from "../../utils/paths.ts";
|
import { getCwdRelativePath } from "../../utils/paths.ts";
|
||||||
@@ -770,7 +770,7 @@ export class InteractiveMode {
|
|||||||
rawKeyHint("!!", "to run bash (no context)"),
|
rawKeyHint("!!", "to run bash (no context)"),
|
||||||
hint("app.message.followUp", "to queue follow-up"),
|
hint("app.message.followUp", "to queue follow-up"),
|
||||||
hint("app.message.dequeue", "to edit all queued messages"),
|
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"),
|
rawKeyHint("drop files", "to attach"),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const compactInstructions = [
|
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.defaultEditor.onPasteImage = () => {
|
||||||
this.handleClipboardImagePaste();
|
void this.handleClipboardPaste();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleClipboardImagePaste(): Promise<void> {
|
private async handleClipboardPaste(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const image = await readClipboardImage();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to temp file
|
const text = await readClipboardText();
|
||||||
const tmpDir = os.tmpdir();
|
if (text) {
|
||||||
const ext = extensionForImageMimeType(image.mimeType) ?? "png";
|
this.editor.insertTextAtCursor?.(text);
|
||||||
const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
|
this.ui.requestRender();
|
||||||
const filePath = path.join(tmpDir, fileName);
|
}
|
||||||
fs.writeFileSync(filePath, Buffer.from(image.bytes));
|
|
||||||
|
|
||||||
// Insert file path directly
|
|
||||||
this.editor.insertTextAtCursor?.(filePath);
|
|
||||||
this.ui.requestRender();
|
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore clipboard errors (may not have permission, etc.)
|
// Silently ignore clipboard errors (may not have permission, etc.)
|
||||||
}
|
}
|
||||||
@@ -5795,7 +5799,7 @@ export class InteractiveMode {
|
|||||||
| \`${copyMessage}\` | Copy last assistant message |
|
| \`${copyMessage}\` | Copy last assistant message |
|
||||||
| \`${followUp}\` | Queue follow-up message |
|
| \`${followUp}\` | Queue follow-up message |
|
||||||
| \`${dequeue}\` | Restore queued messages |
|
| \`${dequeue}\` | Restore queued messages |
|
||||||
| \`${pasteImage}\` | Paste image from clipboard |
|
| \`${pasteImage}\` | Paste image or text from clipboard |
|
||||||
| \`/\` | Slash commands |
|
| \`/\` | Slash commands |
|
||||||
| \`!\` | Run bash command |
|
| \`!\` | Run bash command |
|
||||||
| \`!!\` | Run bash command (excluded from context) |
|
| \`!!\` | Run bash command (excluded from context) |
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { dirname, join } from "path";
|
|||||||
import { pathToFileURL } from "url";
|
import { pathToFileURL } from "url";
|
||||||
|
|
||||||
export type ClipboardModule = {
|
export type ClipboardModule = {
|
||||||
|
getText: () => Promise<string>;
|
||||||
setText: (text: string) => Promise<void>;
|
setText: (text: string) => Promise<void>;
|
||||||
hasImage: () => boolean;
|
hasImage: () => boolean;
|
||||||
getImageBinary: () => Promise<Array<number>>;
|
getImageBinary: () => Promise<Array<number>>;
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ function emitOsc52(text: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read plain text from the system clipboard, if native clipboard access is available. */
|
||||||
|
export async function readClipboardText(): Promise<string | null> {
|
||||||
|
if (!clipboard) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await clipboard.getText();
|
||||||
|
return text || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function copyToClipboard(text: string): Promise<void> {
|
export async function copyToClipboard(text: string): Promise<void> {
|
||||||
let copied = false;
|
let copied = false;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboar
|
|||||||
type ClipboardRequire = (id: string) => unknown;
|
type ClipboardRequire = (id: string) => unknown;
|
||||||
|
|
||||||
const fakeClipboard: ClipboardModule = {
|
const fakeClipboard: ClipboardModule = {
|
||||||
|
getText: async () => "",
|
||||||
setText: async () => {},
|
setText: async () => {},
|
||||||
hasImage: () => true,
|
hasImage: () => true,
|
||||||
getImageBinary: async () => [1, 2, 3],
|
getImageBinary: async () => [1, 2, 3],
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { execSync, spawn } from "child_process";
|
import { execSync, spawn } from "child_process";
|
||||||
import { platform } from "os";
|
import { platform } from "os";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
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(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
return {
|
return {
|
||||||
clipboard: {
|
clipboard: {
|
||||||
|
getText: vi.fn<() => Promise<string>>(),
|
||||||
setText: vi.fn<(text: string) => Promise<void>>(),
|
setText: vi.fn<(text: string) => Promise<void>>(),
|
||||||
},
|
},
|
||||||
execSync: vi.fn(),
|
execSync: vi.fn(),
|
||||||
@@ -59,6 +60,7 @@ beforeEach(() => {
|
|||||||
vi.stubEnv("MOSH_CONNECTION", "");
|
vi.stubEnv("MOSH_CONNECTION", "");
|
||||||
stdoutWrites = [];
|
stdoutWrites = [];
|
||||||
nativeResolved = false;
|
nativeResolved = false;
|
||||||
|
mocks.clipboard.getText.mockReset();
|
||||||
mocks.clipboard.setText.mockReset();
|
mocks.clipboard.setText.mockReset();
|
||||||
mocks.execSync.mockReset();
|
mocks.execSync.mockReset();
|
||||||
mocks.spawn.mockReset();
|
mocks.spawn.mockReset();
|
||||||
@@ -66,6 +68,7 @@ beforeEach(() => {
|
|||||||
mocks.isWaylandSession.mockReset();
|
mocks.isWaylandSession.mockReset();
|
||||||
mockedPlatform.mockReturnValue("darwin");
|
mockedPlatform.mockReturnValue("darwin");
|
||||||
mocks.isWaylandSession.mockReturnValue(false);
|
mocks.isWaylandSession.mockReturnValue(false);
|
||||||
|
mocks.clipboard.getText.mockResolvedValue("");
|
||||||
mocks.clipboard.setText.mockImplementation(async () => {
|
mocks.clipboard.setText.mockImplementation(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
nativeResolved = true;
|
nativeResolved = true;
|
||||||
@@ -86,6 +89,21 @@ afterEach(() => {
|
|||||||
vi.unstubAllEnvs();
|
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", () => {
|
describe("copyToClipboard", () => {
|
||||||
test("local native success skips OSC 52 and shell fallbacks", async () => {
|
test("local native success skips OSC 52 and shell fallbacks", async () => {
|
||||||
await copyToClipboard("hello");
|
await copyToClipboard("hello");
|
||||||
|
|||||||
Reference in New Issue
Block a user