fix(coding-agent): assemble bracketed pastes across stdin chunks before routing

Address PR #3602 review feedback from chatgpt-codex-connector:
when the terminal fragments a bracketed paste across stdin chunks
(\x1b[200~ in one read, \x1b[201~ in the next — Windows Terminal
under load, certain SSH muxes, tmux extended-keys passthrough),
the previous single-chunk `isEmptyBracketedPaste` /
`extractBracketedImagePastePaths` guards never saw both markers
in the same `handleInput` call. The inherited
`BracketedPasteHandler` then buffered the run as a zero-length
text paste and Cmd+V still disappeared.

CustomEditor now owns its own BracketedPasteHandler that runs
ahead of `super.handleInput`, so split bracketed pastes resolve
to a single assembled payload before any routing decision:

- empty payload -> onPasteImage (Cmd+V macOS image-only screenshot)
- image-file paths -> onPasteImagePath (#3506 also gains split-chunk
  coverage as a bonus)
- everything else -> base editor's public `pasteText` so the
  `[Paste #N]` markers, autocomplete, and undo state stay intact

Removed the now-redundant single-chunk `isEmptyBracketedPaste`
helper. New repro tests cover the split-chunk empty paste, the
split-chunk image-file path, and a split-chunk text paste
forwarding exactly once to the base editor.
This commit is contained in:
roboomp
2026-06-26 23:50:59 +00:00
parent 4e5d3eb799
commit f2db96c819
3 changed files with 106 additions and 45 deletions
@@ -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<KeyId, () => void>();
#customMatchKeys = new Map<string, () => 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;
}