diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 78c73a9a3..a7fa23736 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -33,7 +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)) +- 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. `CustomEditor` now runs its own `BracketedPasteHandler` ahead of the inherited handler so the assembled paste payload is routed regardless of whether the start marker, payload, and end marker arrive in one stdin chunk or are fragmented across several (Windows Terminal under load, certain SSH muxes, tmux extended-keys passthrough, …). Strict-zero-length assembled payloads route to the same `onPasteImage` smart reader the `app.clipboard.pasteImage` keybind uses (attaches the clipboard image, or falls back to the #1628 smart text paste / "clipboard is empty" diagnostic); explicit image-file paths route to `onPasteImagePath` (the #3506 path also benefits from split-chunk assembly); everything else hands off to the base editor's `pasteText` so `[Paste #N]` markers, autocomplete, and undo state stay intact. Whitespace-only pastes are preserved as literal text. ([#3601](https://github.com/can1357/oh-my-pi/issues/3601)) ## [16.1.23] - 2026-06-26 diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 98c09c51f..c923798c4 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui"; +import { BracketedPasteHandler } from "@oh-my-pi/pi-tui/bracketed-paste"; import type { AppKeybinding } from "../../config/keybindings"; import { isSettingsInitialized, settings } from "../../config/settings"; import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references"; @@ -229,33 +230,22 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi return paths?.every(isImagePath) ? paths : undefined; } +/** + * Same shape as {@link extractBracketedImagePastePaths} but operates on a + * payload that has already been stripped of the `\x1b[200~` / `\x1b[201~` + * markers — used by the assembled-paste router in {@link CustomEditor.handleInput} + * so split bracketed pastes get the same image-path detection as single-chunk ones. + */ +export function extractImagePastePathsFromText(text: string): string[] | undefined { + const paths = extractPastePathsFromText(text); + return paths?.every(isImagePath) ? paths : undefined; +} + export function extractBracketedImagePastePath(data: string): string | undefined { const paths = extractBracketedImagePastePaths(data); return paths?.length === 1 ? paths[0] : undefined; } -/** - * `true` when `data` is exactly one complete bracketed paste with a - * strictly-zero-length payload. 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`. - * - * Strict-empty is deliberate: a legitimate whitespace-only paste (copying - * indentation, blank-line padding, an SSH/headless session whose clipboard - * helpers return empty, …) must reach the editor as literal whitespace - * instead of being silently replaced by a clipboard-image read whose - * fallback is "Clipboard is empty". - */ -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 endIndex === BRACKETED_PASTE_START.length; -} - /** * Return a single image file path when `text` is exactly one explicit path * pointing at a supported image extension (`.png`, `.jpg`/`.jpeg`, `.gif`, @@ -444,6 +434,11 @@ export class CustomEditor extends Editor { /** Custom key handlers from extensions and non-built-in app actions. */ #customKeyHandlers = new Map void>(); #customMatchKeys = new Map void>(); + /** Bracketed-paste assembler that runs ahead of the inherited handler so terminals which + * deliver `\x1b[200~` and `\x1b[201~` in separate stdin chunks still resolve to a single + * assembled payload here; the empty-paste / image-path branches must see the full content, + * not the raw single-chunk byte sequence. */ + #pasteHandler = new BracketedPasteHandler(); /** Spaces actually inserted in the current run; tracked back out when a hold is recognized. */ #spaceRunInserted = 0; /** Consecutive "mechanical" deltas (fast + steady); a sustained run of these confirms a held bar. */ @@ -605,23 +600,36 @@ export class CustomEditor extends Editor { return; } - const pastedImagePaths = extractBracketedImagePastePaths(data); - if (pastedImagePaths && this.onPasteImagePath) { - void (async () => { - for (const path of pastedImagePaths) await this.onPasteImagePath?.(path); - })(); - 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(); + // Bracketed-paste assembly. Some terminals fragment the start marker, + // the payload, and the end marker across separate stdin chunks + // (Windows Terminal under heavy load, certain SSH muxes, …); the + // inherited handler then sees a zero-length payload and silently + // drops it through the normal text-insert path. Running our own + // `BracketedPasteHandler` ahead of `super.handleInput` lets us route + // the assembled content regardless of chunk boundaries: + // - empty payload → `onPasteImage` (#3601: `Cmd+V`/`Ctrl+V` on an + // image-only macOS pasteboard the terminal stripped to `""` first); + // - explicit image-file paths → `onPasteImagePath` (#3506); + // - anything else → the base editor's `pasteText` so `[Paste #N]` + // markers, autocomplete, and undo state stay intact. + const paste = this.#pasteHandler.process(data); + if (paste.handled) { + if (paste.pasteContent === undefined) return; // still buffering — wait for end marker + const content = paste.pasteContent; + const remaining = paste.remaining; + if (content.length === 0 && this.onPasteImage) { + void this.onPasteImage(); + } else { + const imagePaths = extractImagePastePathsFromText(content); + if (imagePaths && this.onPasteImagePath) { + void (async () => { + for (const p of imagePaths) await this.onPasteImagePath?.(p); + })(); + } else { + this.pasteText(content); + } + } + if (remaining.length > 0) this.handleInput(remaining); return; } diff --git a/packages/coding-agent/test/issue-3601-repro.test.ts b/packages/coding-agent/test/issue-3601-repro.test.ts index 4ea5a8c9c..d10a71d68 100644 --- a/packages/coding-agent/test/issue-3601-repro.test.ts +++ b/packages/coding-agent/test/issue-3601-repro.test.ts @@ -40,7 +40,6 @@ const ONE_PX_PNG = Buffer.from( function createCtx() { const editor = new CustomEditor(getEditorTheme()); - const pasteText = vi.fn(); const requestRender = vi.fn(); const showStatus = vi.fn(); const ctx = { @@ -52,10 +51,11 @@ function createCtx() { } 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 } }; + // Leave `editor.pasteText` intact — the post-fix CustomEditor routes + // real text through it, so the `getText()` assertions below depend on + // the base editor actually writing into its buffer. Tests that need to + // observe the call install a local spy after construction. + return { ctx, editor, spies: { requestRender, showStatus } }; } describe("CustomEditor empty bracketed paste (issue #3601)", () => { @@ -123,6 +123,59 @@ describe("CustomEditor empty bracketed paste (issue #3601)", () => { expect(editor.getText()).toBe(""); }); + + it("invokes onPasteImage when the empty bracketed paste is split across stdin chunks (Codex PR #3602 review)", () => { + // Some terminals (Windows Terminal under load, certain SSH muxes, …) + // fragment a bracketed paste so the start marker, payload, and end + // marker land in separate `handleInput` calls. The pre-fix guard saw + // each fragment in isolation, matched neither, and let the inherited + // handler buffer the run as a zero-length text paste — the same + // silent-drop symptom #3601 already documents for the single-chunk + // case. The post-fix `CustomEditor` runs its own bracketed-paste + // assembler, so the assembled empty payload still routes to the + // smart clipboard reader. + const { editor } = createCtx(); + const onPasteImage = vi.fn(async () => true); + editor.onPasteImage = onPasteImage; + + editor.handleInput(BRACKETED_PASTE_START); + expect(onPasteImage).not.toHaveBeenCalled(); + editor.handleInput(BRACKETED_PASTE_END); + + expect(onPasteImage).toHaveBeenCalledTimes(1); + expect(editor.getText()).toBe(""); + }); + + it("routes an image-file path that arrives split across stdin chunks to onPasteImagePath", () => { + // Same chunking hazard as the empty-paste case, but for an explicit + // image-file path (#3506). The assembled router re-runs the path + // detection over the joined payload so the image still attaches. + const { editor } = createCtx(); + const onPasteImagePath = vi.fn(); + editor.onPasteImagePath = onPasteImagePath; + + editor.handleInput(`${BRACKETED_PASTE_START}/tmp/sc`); + editor.handleInput(`reenshot.png${BRACKETED_PASTE_END}`); + + expect(onPasteImagePath).toHaveBeenCalledWith("/tmp/screenshot.png"); + }); + + it("forwards a split text paste to the underlying editor exactly once (no double-insertion)", () => { + // Sanity check: when the CustomEditor consumes the bracketed paste + // markers ahead of `super.handleInput`, it must hand the assembled + // payload off via the public `pasteText` API so the base editor's + // undo / autocomplete / `[Paste #N]` machinery still runs — and only + // once, with the actual content. + const { editor } = createCtx(); + const pasteText = vi.fn(); + editor.pasteText = pasteText; + + editor.handleInput(`${BRACKETED_PASTE_START}hello `); + editor.handleInput(`world${BRACKETED_PASTE_END}`); + + expect(pasteText).toHaveBeenCalledTimes(1); + expect(pasteText).toHaveBeenCalledWith("hello world"); + }); }); describe("InputController + empty bracketed paste end-to-end (issue #3601)", () => {