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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user