From d0d5b547ff33bc4d9be1de8f1cc4ffb66274bdc5 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 13:34:22 +0200 Subject: [PATCH] feat(editor): added atomic deletion and metadata display for image and paste markers - Added atomicTokenPattern support to treat image and paste markers as indivisible tokens, enabling atomic deletion on backspace and forward-delete instead of character-by-character removal. - Extended image marker format to include pixel dimensions [Image #N, WxH] and updated paste marker format to [Paste #N, +X lines] or [Paste #N, Y chars] with consistent comma-separated metadata. - Implemented accent styling for paste markers in editor rendering while preserving hyperlinks for image markers, improving visual distinction between marker types. - Refactored image reference handling to unified placeholder system supporting both image and paste markers with kind-aware rendering callbacks and regex patterns. --- packages/coding-agent/CHANGELOG.md | 11 ++ .../src/modes/components/custom-editor.ts | 18 ++- .../src/modes/components/user-message.ts | 9 +- .../src/modes/controllers/input-controller.ts | 16 ++- .../src/modes/image-references.ts | 20 ++-- .../coding-agent/test/hook-editor.test.ts | 4 +- .../components/user-message-keywords.test.ts | 21 ++++ .../test/modes/image-references.test.ts | 45 ++++++++ packages/tui/CHANGELOG.md | 8 ++ packages/tui/src/components/editor.ts | 109 ++++++++++++++---- packages/tui/test/editor.test.ts | 57 ++++++++- 11 files changed, 275 insertions(+), 43 deletions(-) create mode 100644 packages/coding-agent/test/modes/image-references.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 650e74a91..d605c963a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog ## [Unreleased] + +### Added + +- Added support for paste marker highlighting with accent styling (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) in the prompt editor, matching the visual treatment of image references +- Added pixel dimensions to pasted/loaded image placeholders in the prompt — the marker now reads `[Image #N, WxH]` (falling back to `[Image #N]` when the header can't be decoded). + +### Changed + +- Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded +- Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. + ### Fixed - Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index f0742da2e..73e42aa46 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -1,6 +1,6 @@ import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui"; import type { AppKeybinding } from "../../config/keybindings"; -import { imageReferenceHyperlink, renderImageReferences } from "../image-references"; +import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references"; import { highlightMagicKeywords } from "../magic-keywords"; import { theme } from "../theme/theme"; @@ -76,16 +76,22 @@ export function extractBracketedImagePastePath(data: string): string | undefined export class CustomEditor extends Editor { imageLinks?: readonly (string | undefined)[]; + /** Treat image/paste markers as indivisible: a stray backspace deletes the whole token + * instead of corrupting `[Paste #1, +30 lines]` into plain text. */ + override atomicTokenPattern = PLACEHOLDER_REGEX; + /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make * pasted image placeholders visually distinct and hyperlink them once their blob file exists. */ decorateText = (text: string): string => - renderImageReferences(text, { + renderPlaceholders(text, { renderText: value => highlightMagicKeywords(value), - renderReference: (value, index) => - imageReferenceHyperlink(value, index, this.imageLinks, label => - theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`), - ), + renderReference: (value, kind, index) => + kind === "image" + ? imageReferenceHyperlink(value, index, this.imageLinks, label => + theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`), + ) + : theme.fg("accent", `\x1b[1m${value}\x1b[22m`), }); onEscape?: () => void; onClear?: () => void; diff --git a/packages/coding-agent/src/modes/components/user-message.ts b/packages/coding-agent/src/modes/components/user-message.ts index 6a1c8e81c..dc2614cf4 100644 --- a/packages/coding-agent/src/modes/components/user-message.ts +++ b/packages/coding-agent/src/modes/components/user-message.ts @@ -1,6 +1,6 @@ import { Container, Markdown } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; -import { imageReferenceHyperlink, renderImageReferences } from "../image-references"; +import { imageReferenceHyperlink, renderPlaceholders } from "../image-references"; import { highlightMagicKeywords } from "../magic-keywords"; // OSC 133 shell integration: marks prompt zones for terminal multiplexers @@ -26,9 +26,12 @@ export class UserMessageComponent extends Container { : (value: string) => theme.fg("userMessageText", highlightMagicKeywords(value, keywordReset)); const imageLabel = (value: string) => theme.fg("accent", `\x1b[1m\x1b[4m${value}\x1b[24m\x1b[22m`); const color = (value: string) => - renderImageReferences(value, { + renderPlaceholders(value, { renderText: baseText, - renderReference: (label, index) => imageReferenceHyperlink(label, index, imageLinks, imageLabel), + renderReference: (label, kind, index) => + kind === "image" + ? imageReferenceHyperlink(label, index, imageLinks, imageLabel) + : theme.fg("accent", `\x1b[1m${label}\x1b[22m`), }); this.addChild( new Markdown(text, 1, 1, getMarkdownTheme(), { diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 937bd31b0..2a2aff7d3 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -729,10 +729,24 @@ export class InputController { this.ctx.pendingImageLinks.push(imageLink); this.ctx.editor.imageLinks = this.ctx.pendingImageLinks; const imageNum = this.ctx.pendingImages.length; - this.ctx.editor.insertText(`[Image #${imageNum}] `); + const dims = await this.#imageDimensions(imageData); + const label = dims ? `[Image #${imageNum}, ${dims.width}x${dims.height}]` : `[Image #${imageNum}]`; + this.ctx.editor.insertText(`${label} `); this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true }); } + /** Probe pixel dimensions for the marker label (`[Image #N, WxH]`). Returns undefined when the + * header can't be decoded, so the caller falls back to a bare `[Image #N]`. */ + async #imageDimensions(image: ImageContent): Promise<{ width: number; height: number } | undefined> { + try { + const { width, height } = await new Bun.Image(Buffer.from(image.data, "base64")).metadata(); + if (width && height) return { width, height }; + } catch { + // Unknown/corrupt header — fall back to a bare label. + } + return undefined; + } + async #normalizeAndInsertPastedImage(image: ImageContent, unsupportedMessage: string): Promise { let imageData = await ensureSupportedImageInput(image); if (!imageData) { diff --git a/packages/coding-agent/src/modes/image-references.ts b/packages/coding-agent/src/modes/image-references.ts index 1d97e73ee..9dae460cf 100644 --- a/packages/coding-agent/src/modes/image-references.ts +++ b/packages/coding-agent/src/modes/image-references.ts @@ -3,30 +3,36 @@ import { logger } from "@oh-my-pi/pi-utils"; import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store"; import { fileHyperlink } from "../tui/hyperlink"; -const IMAGE_REFERENCE_REGEX = /\[Image #([1-9]\d*)\]/g; +/** Matches `[Image #N]`/`[Image #N, WxH]` and `[Paste #N, +X lines]`/`[Paste #N, Y chars]` tokens. + * Group 1 is the kind (`Image`/`Paste`), group 2 the 1-based index. The optional metadata + * tail (`, …`) is captured loosely (no `]`/newline) so future label tweaks keep matching. */ +export const PLACEHOLDER_REGEX = /\[(Image|Paste) #([1-9]\d*)(?:,[^\]\n]*)?\]/g; type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise; type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult; -export interface ImageReferenceRenderers { +export type PlaceholderKind = "image" | "paste"; + +export interface PlaceholderRenderers { renderText: (text: string) => string; - renderReference: (label: string, index: number) => string; + renderReference: (label: string, kind: PlaceholderKind, index: number) => string; } -export function renderImageReferences(text: string, renderers: ImageReferenceRenderers): string { - IMAGE_REFERENCE_REGEX.lastIndex = 0; +export function renderPlaceholders(text: string, renderers: PlaceholderRenderers): string { + PLACEHOLDER_REGEX.lastIndex = 0; let result = ""; let last = 0; let matched = false; for (;;) { - const match = IMAGE_REFERENCE_REGEX.exec(text); + const match = PLACEHOLDER_REGEX.exec(text); if (match === null) break; matched = true; if (match.index > last) { result += renderers.renderText(text.slice(last, match.index)); } - result += renderers.renderReference(match[0], Number(match[1])); + const kind: PlaceholderKind = match[1] === "Paste" ? "paste" : "image"; + result += renderers.renderReference(match[0], kind, Number(match[2])); last = match.index + match[0].length; } diff --git a/packages/coding-agent/test/hook-editor.test.ts b/packages/coding-agent/test/hook-editor.test.ts index e21752f43..961cd19f3 100644 --- a/packages/coding-agent/test/hook-editor.test.ts +++ b/packages/coding-agent/test/hook-editor.test.ts @@ -150,7 +150,7 @@ describe("HookEditorComponent default (hook) mode", () => { component.handleInput(`\x1b[200~${pasted}\x1b[201~`); - expect(renderText(component)).toContain("[paste #1 +11 lines]"); + expect(renderText(component)).toContain("[Paste #1, +11 lines]"); component.handleInput("\x1b[13;5u"); @@ -228,7 +228,7 @@ describe("HookEditorComponent prompt-style mode", () => { component.handleInput(`\x1b[200~${pasted}\x1b[201~`); - expect(renderText(component)).toContain("[paste #1 +11 lines]"); + expect(renderText(component)).toContain("[Paste #1, +11 lines]"); component.handleInput("\r"); diff --git a/packages/coding-agent/test/modes/components/user-message-keywords.test.ts b/packages/coding-agent/test/modes/components/user-message-keywords.test.ts index 4b962e0f3..b9c31ea53 100644 --- a/packages/coding-agent/test/modes/components/user-message-keywords.test.ts +++ b/packages/coding-agent/test/modes/components/user-message-keywords.test.ts @@ -107,4 +107,25 @@ describe("UserMessageComponent magic-keyword highlighting", () => { expect(raw).toContain("\x1b]8;id="); expect(raw).toContain("file:///tmp/abc123.png"); }); + + it("highlights paste markers in the draft editor without a hyperlink", () => { + const editor = new CustomEditor(getEditorTheme()); + editor.setText("see [Paste #1, +30 lines] now"); + const raw = editor.render(80).join("\n"); + expect(Bun.stripANSI(raw)).toContain("[Paste #1, +30 lines]"); + // The marker label is bold-wrapped (highlighted), unlike surrounding plain text. + expect(raw).toContain("\x1b[1m[Paste #1, +30 lines]"); + // Paste markers are not clickable, so no OSC-8 hyperlink is emitted (contrast with images). + expect(raw).not.toContain("\x1b]8;id="); + }); + + it("hyperlinks the metadata-bearing image marker format", () => { + const editor = new CustomEditor(getEditorTheme()); + editor.imageLinks = ["/tmp/omp-image.png"]; + editor.setText("see [Image #1, 800x600] now"); + const raw = editor.render(80).join("\n"); + expect(Bun.stripANSI(raw)).toContain("[Image #1, 800x600]"); + expect(raw).toContain("\x1b]8;id="); + expect(raw).toContain("file:///tmp/omp-image.png"); + }); }); diff --git a/packages/coding-agent/test/modes/image-references.test.ts b/packages/coding-agent/test/modes/image-references.test.ts new file mode 100644 index 000000000..00baba170 --- /dev/null +++ b/packages/coding-agent/test/modes/image-references.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { type PlaceholderKind, renderPlaceholders } from "../../src/modes/image-references"; + +function capture(text: string): { + out: string; + refs: Array<{ label: string; kind: PlaceholderKind; index: number }>; +} { + const refs: Array<{ label: string; kind: PlaceholderKind; index: number }> = []; + const out = renderPlaceholders(text, { + renderText: t => t, + renderReference: (label, kind, index) => { + refs.push({ label, kind, index }); + return `<${kind}:${index}>`; + }, + }); + return { out, refs }; +} + +describe("renderPlaceholders", () => { + it("classifies image and paste markers with their index and full label", () => { + const { out, refs } = capture("see [Image #1, 800x600] then [Paste #2, +30 lines] done"); + expect(refs).toEqual([ + { label: "[Image #1, 800x600]", kind: "image", index: 1 }, + { label: "[Paste #2, +30 lines]", kind: "paste", index: 2 }, + ]); + expect(out).toBe("see then done"); + }); + + it("matches the bare image form and the char-count paste form", () => { + expect(capture("[Image #3]").refs[0]).toMatchObject({ kind: "image", index: 3 }); + expect(capture("[Paste #4, 1500 chars]").refs[0]).toMatchObject({ kind: "paste", index: 4 }); + }); + + it("passes plain text straight through renderText with no references", () => { + const { out, refs } = capture("no markers here"); + expect(refs).toHaveLength(0); + expect(out).toBe("no markers here"); + }); + + it("does not treat an unterminated marker as a reference", () => { + // This is the half-eaten state atomic deletion prevents — it must render as plain text. + const { refs } = capture("[Paste #1, +30 lines"); + expect(refs).toHaveLength(0); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index faa86be6f..4542a6abb 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- Added `atomicTokenPattern` to `Editor`: when set to a global regex matching placeholder tokens such as `[Image #1, 800x600]` or `[Paste #2, +30 lines]`, a single backspace or forward-delete landing anywhere on a token removes the whole token instead of corrupting it into stray text. + +### Changed + +- Changed the large-paste placeholder label from `[paste #N +X lines]`/`[paste #N Y chars]` to `[Paste #N, +X lines]`/`[Paste #N, Y chars]`. + ### Fixed - Fixed pasting large text lagging the prompt for hundreds of milliseconds before the `[paste #N …]` placeholder appeared. `StdinBuffer` assembled bracketed pastes by re-concatenating and re-scanning the entire accumulated buffer on every incoming stdin chunk (`#pasteBuffer += chunk; indexOf(END)`), which is O(n²) in the paste size and dominates when the terminal/PTY delivers the paste in many small reads (SSH, tmux, slow hosts) — a 1 MB paste at 1 KB chunks cost ~33 ms and 5 MB ~740 ms. Chunks are now collected in an array and joined once when the end marker arrives, with a short overlap tail carried across chunk boundaries so a marker split between two reads is still detected without rescanning, making assembly O(n) (~1 ms for 5 MB). The `Editor` paste cleaner also dropped its `split("").filter().join("")` per-code-unit array allocation in favor of a single control-character regex pass (~20× faster on large pastes). diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 3f7a9fd76..7725f2a42 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -368,6 +368,15 @@ export class Editor implements Component, Focusable { #pastes: Map = new Map(); #pasteCounter: number = 0; + /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or + * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete + * landing on any character of a token removes the whole token instead of corrupting it into + * stray text. MUST be a global regex; the editor recompiles a private copy so its `lastIndex` + * is never shared with the caller. */ + atomicTokenPattern: RegExp | undefined; + #atomicTokenSource: string | undefined; + #atomicTokenRe: RegExp | undefined; + // Bracketed paste mode buffering #pasteHandler = new BracketedPasteHandler(); @@ -1389,7 +1398,7 @@ export class Editor implements Component, Focusable { #expandPasteMarkers(text: string): string { let result = text; for (const [pasteId, pasteContent] of this.#pastes) { - const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); + const markerRegex = new RegExp(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`, "g"); result = result.replace(markerRegex, () => pasteContent); } return result; @@ -1653,11 +1662,11 @@ export class Editor implements Component, Focusable { const pasteId = this.#pasteCounter; this.#pastes.set(pasteId, filteredText); - // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]" + // Insert marker like "[Paste #1, +123 lines]" or "[Paste #1, 1234 chars]" const marker = pastedLines.length > 10 - ? `[paste #${pasteId} +${pastedLines.length} lines]` - : `[paste #${pasteId} ${totalChars} chars]`; + ? `[Paste #${pasteId}, +${pastedLines.length} lines]` + : `[Paste #${pasteId}, ${totalChars} chars]`; this.#insertTextAtCursor(marker); return; @@ -1726,26 +1735,72 @@ export class Editor implements Component, Focusable { if (this.onSubmit) this.onSubmit(result); } + /** Resolve the compiled, global copy of `atomicTokenPattern`, rebuilt only when the source changes. */ + #getAtomicTokenRe(): RegExp | undefined { + const pattern = this.atomicTokenPattern; + if (pattern === undefined) { + this.#atomicTokenSource = undefined; + this.#atomicTokenRe = undefined; + return undefined; + } + if (pattern.source !== this.#atomicTokenSource) { + this.#atomicTokenSource = pattern.source; + this.#atomicTokenRe = new RegExp( + pattern.source, + pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`, + ); + } + return this.#atomicTokenRe; + } + + /** Find an atomic token on `line` whose span contains column `col` (`start <= col < end`). */ + #atomicTokenAt(line: string, col: number): { start: number; end: number } | undefined { + const re = this.#getAtomicTokenRe(); + if (re === undefined) return undefined; + re.lastIndex = 0; + for (;;) { + const match = re.exec(line); + if (match === null) break; + if (match[0].length === 0) { + re.lastIndex = match.index + 1; + continue; + } + const start = match.index; + const end = start + match[0].length; + if (col < start) break; + if (col < end) return { start, end }; + } + return undefined; + } + #handleBackspace(): void { this.#historyIndex = -1; // Exit history browsing mode this.#resetKillSequence(); this.#recordUndoState(); if (this.#state.cursorCol > 0) { - // Delete grapheme before cursor (handles emojis, combining characters, etc.) const line = this.#state.lines[this.#state.cursorLine] || ""; - const beforeCursor = line.slice(0, this.#state.cursorCol); + // An atomic placeholder token (image/paste marker) deletes as a unit, so a single + // backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text. + const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1); + if (token !== undefined) { + this.#state.lines[this.#state.cursorLine] = line.slice(0, token.start) + line.slice(token.end); + this.#setCursorCol(token.start); + } else { + // Delete grapheme before cursor (handles emojis, combining characters, etc.) + const beforeCursor = line.slice(0, this.#state.cursorCol); - // Find the last grapheme in the text before cursor - const graphemes = [...segmenter.segment(beforeCursor)]; - const lastGrapheme = graphemes[graphemes.length - 1]; - const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; + // Find the last grapheme in the text before cursor + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; - const before = line.slice(0, this.#state.cursorCol - graphemeLength); - const after = line.slice(this.#state.cursorCol); + const before = line.slice(0, this.#state.cursorCol - graphemeLength); + const after = line.slice(this.#state.cursorCol); - this.#state.lines[this.#state.cursorLine] = before + after; - this.#setCursorCol(this.#state.cursorCol - graphemeLength); + this.#state.lines[this.#state.cursorLine] = before + after; + this.#setCursorCol(this.#state.cursorCol - graphemeLength); + } } else if (this.#state.cursorLine > 0) { // Merge with previous line const currentLine = this.#state.lines[this.#state.cursorLine] || ""; @@ -2217,17 +2272,25 @@ export class Editor implements Component, Focusable { const currentLine = this.#state.lines[this.#state.cursorLine] || ""; if (this.#state.cursorCol < currentLine.length) { - // Delete grapheme at cursor position (handles emojis, combining characters, etc.) - const afterCursor = currentLine.slice(this.#state.cursorCol); + // An atomic placeholder token (image/paste marker) deletes as a unit. + const token = this.#atomicTokenAt(currentLine, this.#state.cursorCol); + if (token !== undefined) { + this.#state.lines[this.#state.cursorLine] = + currentLine.slice(0, token.start) + currentLine.slice(token.end); + this.#setCursorCol(token.start); + } else { + // Delete grapheme at cursor position (handles emojis, combining characters, etc.) + const afterCursor = currentLine.slice(this.#state.cursorCol); - // Find the first grapheme at cursor - const graphemes = [...segmenter.segment(afterCursor)]; - const firstGrapheme = graphemes[0]; - const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; + // Find the first grapheme at cursor + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; - const before = currentLine.slice(0, this.#state.cursorCol); - const after = currentLine.slice(this.#state.cursorCol + graphemeLength); - this.#state.lines[this.#state.cursorLine] = before + after; + const before = currentLine.slice(0, this.#state.cursorCol); + const after = currentLine.slice(this.#state.cursorCol + graphemeLength); + this.#state.lines[this.#state.cursorLine] = before + after; + } } else if (this.#state.cursorLine < this.#state.lines.length - 1) { // At end of line - merge with next line const nextLine = this.#state.lines[this.#state.cursorLine + 1] || ""; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 29f7d1130..6431c6400 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2053,7 +2053,7 @@ describe("Editor component", () => { editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); - expect(editor.getText()).toMatch(/\[paste #\d+ \+\d+ lines\]/); + expect(editor.getText()).toMatch(/\[Paste #\d+, \+\d+ lines\]/); expect(editor.getExpandedText()).toBe(pastedText); }); @@ -2082,6 +2082,61 @@ describe("Editor component", () => { expect(submitted).toBe(pastedText); }); + + it("formats a large single-line paste as a char-count marker", () => { + const editor = new Editor(defaultEditorTheme); + const pastedText = "a".repeat(1500); + + editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); + + expect(editor.getText()).toMatch(/^\[Paste #\d+, 1500 chars\]$/); + expect(editor.getExpandedText()).toBe(pastedText); + }); + + it("deletes an entire paste marker on a single backspace when atomicTokenPattern is set", () => { + const editor = new Editor(defaultEditorTheme); + editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g; + const pastedText = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join("\n"); + + editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); + expect(editor.getText()).toMatch(/^\[Paste #\d+, \+\d+ lines\]$/); + + // Cursor sits just after the marker; one backspace removes the whole token + // rather than corrupting it into stray `[Paste #1, +12 lines` text. + editor.handleInput("\x7f"); + expect(editor.getText()).toBe(""); + }); + + it("deletes an entire marker on forward-delete from its start", () => { + const editor = new Editor(defaultEditorTheme); + editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g; + editor.setText("[Image #1, 800x600]"); + + editor.handleInput("\x01"); // Ctrl+A → start of line + editor.handleInput("\x1b[3~"); // Delete (forward) + expect(editor.getText()).toBe(""); + }); + + it("removes only the marker and keeps surrounding text on atomic backspace", () => { + const editor = new Editor(defaultEditorTheme); + editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g; + editor.setText("a [Paste #1, +12 lines] b"); + + editor.handleInput("\x05"); // Ctrl+E → end of line + editor.handleInput("\x1b[D"); // left over 'b' + editor.handleInput("\x1b[D"); // left over ' ' — cursor now just after ']' + editor.handleInput("\x7f"); // Backspace deletes the whole marker + expect(editor.getText()).toBe("a b"); + }); + + it("deletes a marker character-by-character when no atomicTokenPattern is set", () => { + const editor = new Editor(defaultEditorTheme); + editor.setText("[Paste #1, +12 lines]"); + + editor.handleInput("\x05"); // Ctrl+E → end of line + editor.handleInput("\x7f"); // Backspace removes only the closing bracket + expect(editor.getText()).toBe("[Paste #1, +12 lines"); + }); }); describe("Korean NFC paste normalization", () => {