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:
roboomp
2026-06-26 20:55:06 +00:00
parent 14c8e09e26
commit 7d655fcfdc
3 changed files with 201 additions and 0 deletions
+1
View File
@@ -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");
});
});