fix(coding-agent): attach clipboard image on empty bracketed paste
macOS terminals (iTerm2, Terminal.app, Warp, Ghostty without OSC 5522, …) intercept Cmd+V and read NSPasteboardTypeString first. For an image-only clipboard (Cmd+Shift+5 screenshot saved to clipboard, Chrome image copy, …) that read returns empty, so the terminal forwards a complete-but-empty bracketed paste — '\x1b[200~\x1b[201~' — to the app. CustomEditor.handleInput inserted that empty payload and the keystroke disappeared, forcing users back to Ctrl+V (which is never intercepted and already routes through handleImagePaste). Add isEmptyBracketedPaste and route a complete, empty-or-whitespace-only bracketed paste through the same onPasteImage smart reader the app.clipboard.pasteImage keybind uses, so Cmd+V attaches the clipboard image (or falls back to the #1628 smart text paste / 'clipboard is empty' diagnostic) instead of silent nothing. Bracketed pastes carrying any text (including the explicit image-file path branch from #3506) keep their existing routing. Fixes #3601
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
- Fixed the TUI usage display failing to resolve a used fraction for limits that only populate `remainingFraction` (no `usedFraction`, `used`/`limit`, or `percent`+`used`). The TUI's local `resolveFraction` was missing the inverted-remaining fallback that the shared `resolveUsedFraction` from `@oh-my-pi/pi-ai` already handles — replaced the local copy with the shared function so the TUI and CLI paths resolve fractions identically.
|
||||
- Fixed long-running SSH command boxes leaving a stale `⏳ SSH: [host]` header above the final `⇄ SSH: [host]` header in terminal scrollback. The SSH renderer now keeps its partial-result chrome on the pending icon/state and opts the block out of stream-commit while `isPartial` holds (via the new `ToolRenderer.provisionalPartialResult` flag honored by `ToolExecutionComponent.isTranscriptBlockCommitStable`), so the stable-prefix ratchet can't promote the partial header to native scrollback only to have the final render strand it above the settled frame ([#3177](https://github.com/can1357/oh-my-pi/issues/3177)).
|
||||
- Fixed Gemini over-planning runs that emit long chains of thinking headers (`**Refining …**`, `## Examining …`) without ever issuing a tool call. The session now interrupts that stream, discards the partial reasoning turn, injects a hidden tool-call reminder, and continues with the corrective context instead of burning the full budget on planning.
|
||||
- Fixed `Cmd+V` on macOS silently dropping image-only clipboard pastes (screenshot via `Cmd+Shift+5` "save to clipboard", Chrome image copy, …) — the user had to fall back to `Ctrl+V`. Follow-up to #3506: that fix handled clipboards exposing a file URL or path; the screenshot path leaves only raw image bytes on the pasteboard. macOS terminals (iTerm2, Terminal.app, Warp, Ghostty without OSC 5522, Windows Terminal forwarding, …) intercept `Cmd+V` and read `NSPasteboardTypeString` first; for an image-only clipboard that read returns `""`, so the terminal forwards a complete-but-empty bracketed paste (`\x1b[200~\x1b[201~`). `CustomEditor.handleInput` inserted the empty payload and the keystroke disappeared. A new `isEmptyBracketedPaste` guard now routes any complete, empty-or-whitespace-only bracketed paste through the same `onPasteImage` smart reader the `app.clipboard.pasteImage` keybind uses, so `Cmd+V` attaches the clipboard image (or falls back to the #1628 smart text paste / "clipboard is empty" diagnostic) instead of silent nothing. Bracketed pastes carrying any text (including the explicit image-file path branch from #3506) keep their existing routing. ([#3601](https://github.com/can1357/oh-my-pi/issues/3601))
|
||||
|
||||
## [16.1.23] - 2026-06-26
|
||||
|
||||
|
||||
@@ -234,6 +234,22 @@ export function extractBracketedImagePastePath(data: string): string | undefined
|
||||
return paths?.length === 1 ? paths[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` when `data` is exactly one complete bracketed paste whose payload is
|
||||
* empty (or whitespace-only). macOS terminals (iTerm2, Warp, Ghostty, …) ask
|
||||
* the OS pasteboard for `NSPasteboardTypeString` when the user hits `Cmd+V`;
|
||||
* an image-only clipboard (e.g. `Cmd+Shift+5` screenshot saved to clipboard,
|
||||
* Chrome image copy) returns `""`, so the terminal still emits the start/end
|
||||
* markers around an empty payload. Without a fallback the keystroke would
|
||||
* dead-end silently and force users back to `Ctrl+V`.
|
||||
*/
|
||||
export function isEmptyBracketedPaste(data: string): boolean {
|
||||
if (!data.startsWith(BRACKETED_PASTE_START)) return false;
|
||||
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
||||
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return false;
|
||||
return data.slice(BRACKETED_PASTE_START.length, endIndex).trim().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a single image file path when `text` is exactly one explicit path
|
||||
* pointing at a supported image extension (`.png`, `.jpg`/`.jpeg`, `.gif`,
|
||||
@@ -591,6 +607,18 @@ export class CustomEditor extends Editor {
|
||||
return;
|
||||
}
|
||||
|
||||
// #3601: a `Cmd+V`/`Ctrl+V` on an image-only clipboard makes terminals
|
||||
// that strip the pasteboard to text first (iTerm2, Warp, Ghostty,
|
||||
// Terminal.app, Windows Terminal, …) forward an empty bracketed paste
|
||||
// — the marker pair with no payload. Route it through the same smart
|
||||
// clipboard reader the `app.clipboard.pasteImage` keybind uses so the
|
||||
// keystroke attaches the image (or falls back to a text paste / empty
|
||||
// notice) instead of disappearing.
|
||||
if (isEmptyBracketedPaste(data) && this.onPasteImage) {
|
||||
void this.onPasteImage();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedKey = parseKey(data);
|
||||
const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Repro for #3601: macOS `Cmd+V` is silently dropped for image-only clipboards.
|
||||
*
|
||||
* Follow-up to #3506 — that fix covered the case where the terminal forwards
|
||||
* the clipboard's text (a file path) verbatim. The remaining symptom is the
|
||||
* macOS screenshot path (Cmd+Shift+5 → "save to clipboard"): the pasteboard
|
||||
* holds raw image bytes with no text representation, so a terminal that
|
||||
* intercepts `Cmd+V` and reads `NSPasteboardTypeString` first (iTerm2,
|
||||
* Terminal.app, Warp, Ghostty without OSC 5522, …) sends an EMPTY bracketed
|
||||
* paste — `\x1b[200~\x1b[201~` — to the app. Without a fallback, the editor
|
||||
* inserts the empty payload and the keystroke disappears. The user has to
|
||||
* fall back to `Ctrl+V`, which is delivered as a normal keypress and routes
|
||||
* through `app.clipboard.pasteImage` → `InputController.handleImagePaste` →
|
||||
* `clipboard.readImage()`.
|
||||
*
|
||||
* Defended contract: a complete, empty bracketed paste MUST invoke the same
|
||||
* `onPasteImage` smart-paste reader that the configured keybind triggers, so
|
||||
* the keystroke either attaches the clipboard image or falls back to the
|
||||
* text-paste / "clipboard is empty" diagnostics — never to silent nothing.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { CustomEditor } from "@oh-my-pi/pi-coding-agent/modes/components/custom-editor";
|
||||
import { getEditorTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
const BRACKETED_PASTE_END = "\x1b[201~";
|
||||
|
||||
const ONE_PX_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function createCtx() {
|
||||
const editor = new CustomEditor(getEditorTheme());
|
||||
const pasteText = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const showStatus = vi.fn();
|
||||
const ctx = {
|
||||
editor,
|
||||
ui: { requestRender, getFocused: () => null } as unknown as InteractiveModeContext["ui"],
|
||||
sessionManager: {
|
||||
getCwd: () => process.cwd(),
|
||||
putBlob: async () => ({ hash: "h", path: "/tmp/h.png", displayPath: "/tmp/h.png" }),
|
||||
} as unknown as InteractiveModeContext["sessionManager"],
|
||||
showStatus,
|
||||
} as unknown as InteractiveModeContext;
|
||||
// `editor.pasteText` is consulted by the smart fallback; spy after construction so the
|
||||
// CustomEditor still owns its real pasteText implementation everywhere else.
|
||||
editor.pasteText = pasteText;
|
||||
return { ctx, editor, spies: { pasteText, requestRender, showStatus } };
|
||||
}
|
||||
|
||||
describe("CustomEditor empty bracketed paste (issue #3601)", () => {
|
||||
it("invokes onPasteImage for an empty bracketed paste so Cmd+V on image-only clipboards reaches the smart reader", () => {
|
||||
const { editor } = createCtx();
|
||||
const onPasteImage = vi.fn(async () => true);
|
||||
editor.onPasteImage = onPasteImage;
|
||||
|
||||
editor.handleInput(`${BRACKETED_PASTE_START}${BRACKETED_PASTE_END}`);
|
||||
|
||||
expect(onPasteImage).toHaveBeenCalledTimes(1);
|
||||
// And the empty payload MUST NOT also fall through to the underlying editor (would
|
||||
// add a literal empty paste / undo entry).
|
||||
expect(editor.getText()).toBe("");
|
||||
});
|
||||
|
||||
it("invokes onPasteImage for a whitespace-only bracketed paste (matches terminals that pad the empty pasteboard read)", () => {
|
||||
const { editor } = createCtx();
|
||||
const onPasteImage = vi.fn(async () => true);
|
||||
editor.onPasteImage = onPasteImage;
|
||||
|
||||
editor.handleInput(`${BRACKETED_PASTE_START} \n${BRACKETED_PASTE_END}`);
|
||||
|
||||
expect(onPasteImage).toHaveBeenCalledTimes(1);
|
||||
expect(editor.getText()).toBe("");
|
||||
});
|
||||
|
||||
it("does not hijack a bracketed paste that carries real text (Ctrl+V text fallback)", () => {
|
||||
const { editor } = createCtx();
|
||||
const onPasteImage = vi.fn(async () => true);
|
||||
editor.onPasteImage = onPasteImage;
|
||||
|
||||
editor.handleInput(`${BRACKETED_PASTE_START}hello world${BRACKETED_PASTE_END}`);
|
||||
|
||||
expect(onPasteImage).not.toHaveBeenCalled();
|
||||
expect(editor.getText()).toBe("hello world");
|
||||
});
|
||||
|
||||
it("does not hijack a bracketed paste that resolves to an explicit image-file path (existing #3506 path)", () => {
|
||||
const { editor } = createCtx();
|
||||
const onPasteImage = vi.fn(async () => true);
|
||||
const onPasteImagePath = vi.fn();
|
||||
editor.onPasteImage = onPasteImage;
|
||||
editor.onPasteImagePath = onPasteImagePath;
|
||||
|
||||
editor.handleInput(`${BRACKETED_PASTE_START}/tmp/screenshot.png${BRACKETED_PASTE_END}`);
|
||||
|
||||
// The image-path branch fires; the empty-paste branch must stay out of the way.
|
||||
expect(onPasteImagePath).toHaveBeenCalledWith("/tmp/screenshot.png");
|
||||
expect(onPasteImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores the empty-paste handler when no onPasteImage is registered (no behavior change for hosts that opt out)", () => {
|
||||
const { editor } = createCtx();
|
||||
// editor.onPasteImage left undefined.
|
||||
|
||||
// MUST not throw, MUST not modify the buffer, MUST not change focus.
|
||||
editor.handleInput(`${BRACKETED_PASTE_START}${BRACKETED_PASTE_END}`);
|
||||
|
||||
expect(editor.getText()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputController + empty bracketed paste end-to-end (issue #3601)", () => {
|
||||
let tmpDir: string;
|
||||
let imgPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "issue-3601-"));
|
||||
imgPath = path.join(tmpDir, "screenshot.png");
|
||||
await fs.writeFile(imgPath, ONE_PX_PNG);
|
||||
resetSettingsForTest();
|
||||
await Settings.init({ inMemory: true, overrides: { "images.autoResize": false } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
resetSettingsForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("end-to-end: empty bracketed paste attaches the clipboard image bytes (image-only macOS screenshot scenario)", async () => {
|
||||
const editor = new CustomEditor(getEditorTheme());
|
||||
const pendingImages: ImageContent[] = [];
|
||||
editor.pendingImages = pendingImages;
|
||||
const requestRender = vi.fn();
|
||||
const showStatus = vi.fn();
|
||||
const ctx = {
|
||||
editor,
|
||||
ui: { requestRender, getFocused: () => null } as unknown as InteractiveModeContext["ui"],
|
||||
sessionManager: {
|
||||
getCwd: () => process.cwd(),
|
||||
putBlob: async () => ({ hash: "h", path: imgPath, displayPath: imgPath }),
|
||||
} as unknown as InteractiveModeContext["sessionManager"],
|
||||
showStatus,
|
||||
} as unknown as InteractiveModeContext;
|
||||
const controller = new InputController(ctx, {
|
||||
readImage: async () => ({ data: ONE_PX_PNG, mimeType: "image/png" }),
|
||||
readText: async () => "", // pbpaste returns empty for image-only pasteboards
|
||||
});
|
||||
// Wire the same dispatch the production setup uses.
|
||||
editor.onPasteImage = () => controller.handleImagePaste();
|
||||
|
||||
editor.handleInput(`${BRACKETED_PASTE_START}${BRACKETED_PASTE_END}`);
|
||||
// Drain all queued microtasks so the editor's `void onPasteImage()` and the
|
||||
// async chain inside `#insertPendingImage` (materializeImageReferenceLinks,
|
||||
// imageDimensions) all finish before assertions run.
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
|
||||
expect(showStatus).not.toHaveBeenCalled();
|
||||
expect(pendingImages.length).toBe(1);
|
||||
expect(pendingImages[0]?.mimeType).toBe("image/png");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user