diff --git a/.changeset/tui-paste-marker-second-paste-expand.md b/.changeset/tui-paste-marker-second-paste-expand.md new file mode 100644 index 00000000000..7a0e585a80b --- /dev/null +++ b/.changeset/tui-paste-marker-second-paste-expand.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Re-pasting a marker's exact content onto it expands the marker again (second-paste gesture); pastes with different content always insert normally. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 2a280209325..0b2c52b6d00 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -15,6 +15,7 @@ import { import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; +import { BRACKET_PASTE_END, BRACKET_PASTE_START, PASTE_PAYLOAD_SUPPRESS_MS } from '#/tui/constant/paste'; import { printableChar } from '#/tui/utils/printable-key'; import { extractAtPrefix } from './file-mention-provider'; @@ -24,10 +25,6 @@ import { WrappingSelectList } from './wrapping-select-list'; // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences const ANSI_SGR = /\u001B\[[0-9;]*m/g; -const PASTE_MARKER_RE = /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g; -const BRACKET_PASTE_START = '\u001B[200~'; -const BRACKET_PASTE_END = '\u001B[201~'; - // Kitty keyboard protocol CSI-u sequence: ESC [ keycode ; modifier[:eventType] u. // We intentionally match only the simple two-field form — enough to rewrite // `ctrl+` with caps_lock into `ctrl+` without caps_lock. @@ -162,6 +159,9 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; + private suppressPastePayloadUntil = 0; + /** Content restored by the latest paste-key expansion, while its trailing payload is pending. */ + private pendingExpandedContent: string | undefined; /** Serialize paste callbacks so Enter/typing cannot overtake an image paste. */ private pasteInFlight = false; private readonly pasteInputQueue: string[] = []; @@ -230,30 +230,30 @@ export class CustomEditor extends Editor { this.onInputModeChange?.(mode); } - private expandPasteMarkerAtCursor(): boolean { - const { line, col } = this.getCursor(); - const lines = this.getLines(); - const currentLine = lines[line] ?? ''; - - for (const match of currentLine.matchAll(PASTE_MARKER_RE)) { - const start = match.index; - const end = start + match[0].length; - if (col < start || col > end) continue; - - const pasteId = Number(match[1]); - const pastes = (this as unknown as { pastes: Map }).pastes; - const content = pastes.get(pasteId); - if (content === undefined) return false; - - const text = this.getText(); - const offset = lines.slice(0, line).reduce((sum, l) => sum + l.length + 1, 0) + start; - const newText = text.slice(0, offset) + content + text.slice(offset + match[0].length); - // Keep the paste registry intact: the text still holds other live markers - // whose entries a plain setText would drop (upstream resets the registry). - this.setText(newText, { preservePasteRegistry: true }); - return true; + /** + * A bracketed-paste payload that arrived right after a paste-key expansion: + * swallow it when it really is the content just expanded (a terminal echo of + * the same gesture); replay it into pi-tui's normal paste path when it is + * genuinely different clipboard content. + */ + private handleTrailingPayload(payload: string): void { + const expected = this.pendingExpandedContent; + this.pendingExpandedContent = undefined; + if (expected === undefined) return; + const start = payload.indexOf(BRACKET_PASTE_START) + BRACKET_PASTE_START.length; + const end = payload.indexOf(BRACKET_PASTE_END, start); + const content = end === -1 ? payload.slice(start) : payload.slice(start, end); + const suffix = end === -1 ? '' : payload.slice(end + BRACKET_PASTE_END.length); + if (this.canonicalizePastedText(content) !== expected) { + this.handleInput(payload); + return; + } + // The payload itself is swallowed, but bytes the terminal batched after it + // are real input and must go through the normal pipeline (pending state is + // already cleared, so this cannot re-enter the suppression path). + if (suffix.length > 0) { + this.handleInput(suffix); } - return false; } private hasAutocompleteActivity(): boolean { @@ -382,26 +382,42 @@ export class CustomEditor extends Editor { this.onNonEscapeInput?.(); } - // When a paste marker was just expanded, discard the trailing bracketed - // paste data that the terminal sends alongside the Ctrl-V keystroke. + // Some terminals deliver the Ctrl-V keystroke and the clipboard's + // bracketed-paste payload together. After a paste-key expansion the + // payload must be swallowed once — but only when its content really is + // what the expansion just restored, so an unrelated in-window paste + // survives; standalone pastes keep flowing to pi-tui's identical-content + // check. if (this.consumingPaste) { this.consumeBuffer += normalized; if (this.consumeBuffer.includes(BRACKET_PASTE_END)) { + const payload = this.consumeBuffer; this.consumingPaste = false; this.consumeBuffer = ''; + this.handleTrailingPayload(payload); } return; } - - // If a bracketed paste arrives while the cursor sits on an existing - // paste marker, expand that marker instead of pasting new content. - if (normalized.includes(BRACKET_PASTE_START) && this.expandPasteMarkerAtCursor()) { + if ( + normalized.includes(BRACKET_PASTE_START) && + this.pendingExpandedContent !== undefined && + Date.now() < this.suppressPastePayloadUntil + ) { + this.suppressPastePayloadUntil = 0; if (!normalized.includes(BRACKET_PASTE_END)) { this.consumingPaste = true; + this.consumeBuffer = normalized; + return; } + this.handleTrailingPayload(normalized); return; } + // Any intervening input proves a later payload is no longer the immediate + // echo of the expansion — disarm the pending suppression. The paste-key + // branch below re-arms it on a successful expansion. + this.pendingExpandedContent = undefined; + // Paste image binding — platform-aware: // Windows terminals reserve Ctrl-V for their own paste handling // (e.g. Windows Terminal's Ctrl+V shortcut), so we listen for @@ -410,7 +426,16 @@ export class CustomEditor extends Editor { // normal paste path so text from the clipboard still works. const pasteKey = process.platform === 'win32' ? 'alt+v' : Key.ctrl('v'); if (matchesKey(normalized, pasteKey)) { - if (this.expandPasteMarkerAtCursor()) { + // A new paste gesture invalidates any unconsumed pending payload from + // the previous one, so it cannot swallow this gesture's real payload. + this.pendingExpandedContent = undefined; + const expanded = this.expandPasteMarkerAtCursor(); + if (expanded !== undefined) { + // Terminals that also forward the clipboard as bracketed paste will + // deliver that payload next — swallow it only if it really is the + // content just expanded. + this.pendingExpandedContent = expanded; + this.suppressPastePayloadUntil = Date.now() + PASTE_PAYLOAD_SUPPRESS_MS; return; } if (this.onPasteImage !== undefined) { diff --git a/apps/kimi-code/src/tui/constant/paste.ts b/apps/kimi-code/src/tui/constant/paste.ts new file mode 100644 index 00000000000..cee0fde6765 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/paste.ts @@ -0,0 +1,4 @@ +export const BRACKET_PASTE_START = '\u001B[200~'; +export const BRACKET_PASTE_END = '\u001B[201~'; +/** Window after a paste-key expansion during which its echoed payload may arrive and be swallowed. */ +export const PASTE_PAYLOAD_SUPPRESS_MS = 1000; diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 6b68d83e197..4b7164391ec 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -474,19 +474,32 @@ describe('CustomEditor paste marker expansion', () => { editor.handleInput(`${PASTE_START}${content}${PASTE_END}`); } - it('expands paste marker when bracketed paste arrives while cursor is on marker', () => { + it('expands the marker when the identical content is pasted onto it again', () => { const editor = makeEditor(); const longText = 'line\n'.repeat(15).trimEnd(); simulateLargePaste(editor, longText); expect(editor.getText()).toMatch(/\[paste #1 \+15 lines\]/); - simulateLargePaste(editor, 'anything'); + simulateLargePaste(editor, longText); expect(editor.getText()).not.toContain('[paste #'); expect(editor.getText()).toContain(longText); }); + it('pastes different content normally even when the cursor is on a marker', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + expect(editor.getText()).toMatch(/\[paste #1 \+15 lines\]/); + + simulateLargePaste(editor, 'anything'); + + expect(editor.getText()).toContain('[paste #1'); + expect(editor.getText()).toContain('anything'); + }); + it('does not expand when cursor is not on a paste marker', () => { const editor = makeEditor(); const longText = 'line\n'.repeat(15).trimEnd(); @@ -516,7 +529,7 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toContain('[paste #1'); expect(editor.getText()).toContain('[paste #2'); - simulateLargePaste(editor, 'anything'); + simulateLargePaste(editor, text2); expect(editor.getText()).toContain('[paste #1'); expect(editor.getText()).not.toContain('[paste #2'); @@ -545,28 +558,28 @@ describe('CustomEditor paste marker expansion', () => { const markerText = editor.getText(); expect(markerText).toMatch(/\[paste #1/); - simulateLargePaste(editor, 'anything'); + simulateLargePaste(editor, longText); expect(editor.getText()).toContain(longText); // Undo (Ctrl+-) restores both the marker text and its paste-registry entry. editor.handleInput('\x1b[45;5u'); expect(editor.getText()).toContain('[paste #1'); - simulateLargePaste(editor, 'anything'); + simulateLargePaste(editor, longText); expect(editor.getText()).not.toContain('[paste #'); expect(editor.getText()).toContain(longText); }); - it('suppresses multi-chunk bracketed paste data after marker expansion', () => { + it('expands the marker when the identical paste arrives split across chunks', () => { const editor = makeEditor(); const longText = 'line\n'.repeat(15).trimEnd(); simulateLargePaste(editor, longText); - editor.handleInput(`${PASTE_START}chunk1`); - editor.handleInput(`chunk2${PASTE_END}`); + const splitAt = Math.floor(longText.length / 2); + editor.handleInput(`${PASTE_START}${longText.slice(0, splitAt)}`); + editor.handleInput(`${longText.slice(splitAt)}${PASTE_END}`); - expect(editor.getText()).not.toContain('chunk1'); - expect(editor.getText()).not.toContain('chunk2'); + expect(editor.getText()).not.toContain('[paste #'); expect(editor.getText()).toContain(longText); }); @@ -580,14 +593,155 @@ describe('CustomEditor paste marker expansion', () => { editor.handleInput('\u001B[20'); editor.handleInput('1~'); - expect(editor.getText()).toContain(longText); - expect(editor.getText()).not.toContain('data'); + expect(editor.getText()).toContain('[paste #1'); + expect(editor.getText()).toContain('data'); // Verify editor is not stuck — next keystrokes should work normally editor.handleInput('x'); expect(editor.getText()).toContain('x'); }); + it('swallows the bracketed-paste payload that trails a paste-key expansion', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + expect(editor.getText()).toBe(longText); + + // Terminals that forward the clipboard as bracketed paste alongside the + // keystroke deliver this payload next — it must not re-paste. + editor.handleInput(`${PASTE_START}${longText}${PASTE_END}`); + expect(editor.getText()).toBe(longText); + }); + + it('swallows a trailing payload that arrives split across chunks', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + expect(editor.getText()).toBe(longText); + + const splitAt = Math.floor(longText.length / 2); + editor.handleInput(`${PASTE_START}${longText.slice(0, splitAt)}`); + editor.handleInput(`${longText.slice(splitAt)}${PASTE_END}`); + expect(editor.getText()).toBe(longText); + + // Verify editor is not stuck — next keystrokes should work normally + editor.handleInput('x'); + expect(editor.getText()).toContain('x'); + }); + + it('lets a standalone paste through after the suppression window expires', () => { + vi.useFakeTimers(); + try { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + expect(editor.getText()).toBe(longText); + + vi.setSystemTime(Date.now() + 1500); + simulateLargePaste(editor, longText); + expect(editor.getText()).toContain('[paste #2'); + } finally { + vi.useRealTimers(); + } + }); + + it('pastes an in-window payload whose content differs from the expansion', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + expect(editor.getText()).toBe(longText); + + // A different clipboard arriving inside the suppression window is not the + // terminal's echo — it must be pasted, not swallowed. + simulateLargePaste(editor, 'anything'); + expect(editor.getText()).toContain('anything'); + }); + + it('swallows a trailing payload whose raw form differs only by normalization', () => { + const editor = makeEditor(); + const raw = Array.from({ length: 12 }, () => 'a\tb').join('\r\n'); + simulateLargePaste(editor, raw); + const normalized = raw.replace(/\r\n/g, '\n').replace(/\t/g, ' '); + expect(editor.getText()).toMatch(/\[paste #1/); + + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + expect(editor.getText()).toBe(normalized); + + // The terminal echoes the raw clipboard (CRLF + tabs), not the normalized + // stored form — it is still the same payload and must be swallowed. + simulateLargePaste(editor, raw); + expect(editor.getText()).toBe(normalized); + }); + + it('swallows the trailing payload when the expansion carried a synthetic leading space', () => { + const editor = makeEditor(); + editor.handleInput('word'); + const paste = '/p\n'.repeat(12).trimEnd(); + simulateLargePaste(editor, paste); + expect(editor.getText()).toMatch(/\[paste #1/); + + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + expect(editor.getText()).toBe(`word ${paste}`); + + // The stored form gained a synthetic leading space at store time; the + // echoed raw payload has none and must still be recognized as the echo. + simulateLargePaste(editor, paste); + expect(editor.getText()).toBe(`word ${paste}`); + }); + + it('forwards input bytes batched after the swallowed payload', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + expect(editor.getText()).toBe(longText); + + // The terminal batches the echoed payload and a subsequent keystroke into + // one chunk — the payload is swallowed but the keystroke must survive. + editor.handleInput(`${PASTE_START}${longText}${PASTE_END}x`); + expect(editor.getText()).toBe(`${longText}x`); + }); + + it('does not swallow a payload that follows a new paste gesture', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + expect(editor.getText()).toBe(longText); + + // A second paste-key press (no marker under the cursor now) starts a new + // gesture and invalidates the first gesture's pending suppression, so this + // payload is pasted instead of being mistaken for the first echo. + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + simulateLargePaste(editor, longText); + expect(editor.getText()).toContain('[paste #2'); + }); + + it('does not swallow a same-content paste after intervening input', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + expect(editor.getText()).toBe(longText); + + // Intervening input proves a later payload is not the immediate echo of + // the expansion, so it pastes normally instead of being swallowed. + editor.handleInput('y'); + simulateLargePaste(editor, longText); + expect(editor.getText()).toContain('[paste #2'); + }); + it('falls back to the text paste path when the image paste handler rejects', async () => { const editor = makeEditor(); const onTextPaste = vi.fn(); diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 3ee3f9a0033..1fdd682b242 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,6 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content and returns the canonical clipboard text that produced it (any synthetic leading space the path-spacing rule added is stripped — that is the value a trailing paste payload should be compared against; `undefined` when the cursor is not on a live marker), preserving the paste registry so undo restores both the marker text and its entry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check compares against the genuine clipboard text — whether a leading space was actually synthesized by the path-spacing rule is recorded per paste id in `pasteSyntheticSpacing` and maintained across marker renumbering, registry clears, and undo snapshots — never inferred from the two values). The raw-payload normalization itself (CSI-u decode, line endings, tabs, non-printables) is shared through the public `canonicalizePastedText()` so the app layer compares payloads on the same canonical form. Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns undefined when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", "does not expand when the re-paste differs by a genuine leading space", "does not expand when a leading space is genuine on the re-paste of path-starting content", "expands the marker when the re-paste receives the synthetic leading space", and "does not expand when the re-paste genuinely carries the space the stored paste synthesized" in `test/editor.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 276cac7e7a1..8f4c0a22114 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -229,6 +229,7 @@ interface EditorSnapshot { state: EditorState; pastes: Map; pasteCounter: number; + pasteSyntheticSpacing: Set; } interface LayoutLine { @@ -328,6 +329,8 @@ export class Editor implements Component, Focusable { // Paste tracking for large pastes private pastes: Map = new Map(); private pasteCounter: number = 0; + /** Paste ids whose stored content carries a synthetic leading space added by the path-spacing rule. */ + private pasteSyntheticSpacing: Set = new Set(); // Bracketed paste mode buffering private pasteBuffer: string = ""; @@ -1132,6 +1135,69 @@ export class Editor implements Component, Focusable { return this.expandPasteMarkers(this.state.lines.join("\n")); } + /** + * Expand the paste marker under the cursor, replacing it with its stored + * content, and return the canonical clipboard text that produced it (any + * synthetic leading space the path-spacing rule added is stripped — that is + * the value a trailing paste payload should be compared against). The paste + * registry is preserved so undo restores both the marker text and its + * entry. Returns undefined when the cursor is not on a live marker. + */ + expandPasteMarkerAtCursor(): string | undefined { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + PASTE_MARKER_REGEX.lastIndex = 0; + for (const match of currentLine.matchAll(PASTE_MARKER_REGEX)) { + const start = match.index; + const end = start + match[0].length; + if (this.state.cursorCol < start || this.state.cursorCol > end) continue; + + const pasteId = Number(match[1]); + const content = this.pastes.get(pasteId); + if (content === undefined) return undefined; + + const text = this.getText(); + const offset = + this.state.lines.slice(0, this.state.cursorLine).reduce((sum, l) => sum + l.length + 1, 0) + + start; + const newText = text.slice(0, offset) + content + text.slice(offset + match[0].length); + this.setText(newText, { preservePasteRegistry: true }); + return this.pasteSyntheticSpacing.has(pasteId) ? content.slice(1) : content; + } + return undefined; + } + + /** + * Second-paste gesture: pasting content identical to what the marker under + * the cursor holds expands that marker instead of inserting a duplicate. + */ + private expandMarkerForIdenticalPaste(filteredText: string, hadSyntheticSpace: boolean): boolean { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + PASTE_MARKER_REGEX.lastIndex = 0; + for (const match of currentLine.matchAll(PASTE_MARKER_REGEX)) { + const start = match.index; + const end = start + match[0].length; + if (this.state.cursorCol < start || this.state.cursorCol > end) continue; + if (!this.isSamePasteContent(Number(match[1]), filteredText, hadSyntheticSpace)) return false; + return this.expandPasteMarkerAtCursor() !== undefined; + } + return false; + } + + /** + * Identity for the second-paste gesture, compared on the genuine clipboard + * text of both sides: a leading space is stripped only when the synthetic + * flag says the path-spacing rule produced it (recorded per paste id at + * store time, known locally for the incoming paste), so a paste that + * genuinely carries that space never counts as identical. + */ + private isSamePasteContent(pasteId: number, pasted: string, pastedHadSyntheticSpace: boolean): boolean { + const stored = this.pastes.get(pasteId); + if (stored === undefined) return false; + const storedCanonical = this.pasteSyntheticSpacing.has(pasteId) ? stored.slice(1) : stored; + const pastedCanonical = pastedHadSyntheticSpace ? pasted.slice(1) : pasted; + return storedCanonical === pastedCanonical; + } + getLines(): string[] { return [...this.state.lines]; } @@ -1152,6 +1218,7 @@ export class Editor implements Component, Focusable { if (!options?.preservePasteRegistry) { this.pastes.clear(); this.pasteCounter = 0; + this.pasteSyntheticSpacing.clear(); } this.setTextInternal(normalized); } @@ -1179,6 +1246,27 @@ export class Editor implements Component, Focusable { return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " "); } + /** + * Normalize raw bracketed-paste text the way handlePaste stores it: + * CSI-u Ctrl+ sequences decoded (tmux popups re-encode control + * bytes that way), line endings normalized, tabs expanded, non-printable + * characters filtered. The context-dependent path-spacing adjustment is + * deliberately not part of this. + */ + canonicalizePastedText(pastedText: string): string { + const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => { + const cp = Number(code); + if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96); + if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64); + return match; + }); + const cleanText = this.normalizeText(decodedText); + return cleanText + .split("") + .filter((char) => char === "\n" || char.charCodeAt(0) >= 32) + .join(""); + } + /** * Internal text insertion at cursor. Handles single and multi-line text. * Does not push undo snapshots or trigger autocomplete - caller is responsible. @@ -1302,39 +1390,28 @@ export class Editor implements Component, Focusable { this.exitHistoryBrowsing(); this.lastAction = null; - this.pushUndoSnapshot(); - - // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode - // control bytes inside bracketed paste as CSI-u Ctrl+ sequences - // (ESC [ ; 5 u). Decode those back to their literal byte so the - // per-char filter below preserves newlines instead of stripping ESC and - // leaking the printable tail (e.g. "[106;5u") into the editor. - const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => { - const cp = Number(code); - if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96); - if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64); - return match; - }); - - // Clean the pasted text: normalize line endings, expand tabs - const cleanText = this.normalizeText(decodedText); - - // Filter out non-printable characters except newlines - let filteredText = cleanText - .split("") - .filter((char) => char === "\n" || char.charCodeAt(0) >= 32) - .join(""); + // Normalize the raw payload (CSI-u decode, line endings, tabs, + // non-printables) the same way stored pastes are stored. + let filteredText = this.canonicalizePastedText(pastedText); // If pasting a file path (starts with /, ~, or .) and the character before // the cursor is a word character, prepend a space for better readability + let hadSyntheticSpace = false; if (/^[/~.]/.test(filteredText)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : ""; if (charBeforeCursor && /\w/.test(charBeforeCursor)) { filteredText = ` ${filteredText}`; + hadSyntheticSpace = true; } } + // Re-pasting a marker's exact stored content onto it expands the marker + // (second-paste gesture) instead of inserting a duplicate. + if (this.expandMarkerForIdenticalPaste(filteredText, hadSyntheticSpace)) return; + + this.pushUndoSnapshot(); + // Split into lines to check for large paste const pastedLines = filteredText.split("\n"); @@ -1345,6 +1422,7 @@ export class Editor implements Component, Focusable { this.pasteCounter++; const pasteId = this.pasteCounter; this.pastes.set(pasteId, filteredText); + if (hadSyntheticSpace) this.pasteSyntheticSpacing.add(pasteId); // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]" const marker = @@ -1408,6 +1486,7 @@ export class Editor implements Component, Focusable { this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.pastes.clear(); this.pasteCounter = 0; + this.pasteSyntheticSpacing.clear(); this.exitHistoryBrowsing(); this.scrollOffset = 0; this.undoStack.clear(); @@ -1438,6 +1517,7 @@ export class Editor implements Component, Focusable { // This contains the id part e.g 4 from [paste #4 +123 lines] const targetId = Number(isPastedSegmented[1]); this.pastes.delete(targetId); + this.pasteSyntheticSpacing.delete(targetId); this.pasteCounter--; // Shift registry entries down in ascending id order, independent @@ -1447,6 +1527,7 @@ export class Editor implements Component, Focusable { for (const id of higherIds) { this.pastes.set(id - 1, this.pastes.get(id)!); this.pastes.delete(id); + if (this.pasteSyntheticSpacing.delete(id)) this.pasteSyntheticSpacing.add(id - 1); } // Renumber markers with ids greater than the removed one. @@ -2154,7 +2235,12 @@ export class Editor implements Component, Focusable { } private pushUndoSnapshot(): void { - this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter }); + this.undoStack.push({ + state: this.state, + pastes: this.pastes, + pasteCounter: this.pasteCounter, + pasteSyntheticSpacing: this.pasteSyntheticSpacing, + }); } private undo(): void { @@ -2164,6 +2250,7 @@ export class Editor implements Component, Focusable { Object.assign(this.state, snapshot.state); this.pastes = snapshot.pastes; this.pasteCounter = snapshot.pasteCounter; + this.pasteSyntheticSpacing = snapshot.pasteSyntheticSpacing; this.lastAction = null; this.preferredVisualCol = null; if (this.onChange) { diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 7ed25e0241c..86208bd5f27 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4053,6 +4053,159 @@ describe("Editor component", () => { assert.match(text, /\[paste #\d+ \+\d+ lines\]/); }); + it("expands the marker when the identical content is pasted onto it again", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1 \+12 lines\]/); + + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.strictEqual(editor.getText(), paste); + }); + + it("pastes different content normally even when the cursor is on a marker", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput(`\x1b[200~${bigPaste("alpha")}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + editor.handleInput(`\x1b[200~different\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("different")); + }); + + it("creates a second marker for the identical content when the cursor is not on the first marker", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + editor.handleInput("x"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("[paste #2")); + }); + + it("expands the marker when the identical paste arrives split across chunks", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + + const splitAt = Math.floor(paste.length / 2); + editor.handleInput(`\x1b[200~${paste.slice(0, splitAt)}`); + editor.handleInput(`${paste.slice(splitAt)}\x1b[201~`); + assert.strictEqual(editor.getText(), paste); + }); + + it("expandPasteMarkerAtCursor replaces the marker with its stored content", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + assert.strictEqual(editor.expandPasteMarkerAtCursor(), paste); + assert.strictEqual(editor.getText(), paste); + }); + + it("expandPasteMarkerAtCursor returns undefined when the cursor is not on a marker", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("plain"); + assert.strictEqual(editor.expandPasteMarkerAtCursor(), undefined); + assert.strictEqual(editor.getText(), "plain"); + }); + + it("undo after a second-paste expansion restores the marker and its registry entry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.strictEqual(editor.getText(), paste); + + editor.handleInput("\x1b[45;5u"); // undo: restores marker text + assert.ok(editor.getText().includes("[paste #1")); + editor.handleInput("\r"); + assert.strictEqual(submitted, paste); + }); + + it("expands the marker on re-paste when the stored content carries a synthetic leading space", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("word"); + // First paste starts with "/" and follows a word character, so the + // path-spacing rule prepends a synthetic space to the stored content. + const paste = "/p\n".repeat(12).trimEnd(); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1 \+12 lines\]/); + + // The re-paste lands after "]", gets no synthetic space, and must + // still be recognized as identical. + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.strictEqual(editor.getText(), `word ${paste}`); + }); + + it("does not expand when the re-paste differs by a genuine leading space", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + // A paste that genuinely differs by one leading space is new content, + // not the expansion gesture. + editor.handleInput(`\x1b[200~ ${paste}\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("[paste #2")); + }); + + it("does not expand when a leading space is genuine on the re-paste of path-starting content", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + // Pasted into the empty editor: no word character before the cursor, + // so the stored content carries no synthetic space. + const paste = "/p\n".repeat(12).trimEnd(); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + // A genuine leading space makes this different content, even though + // the unspaced side starts with a path character. + editor.handleInput(`\x1b[200~ ${paste}\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("[paste #2")); + }); + + it("expands the marker when the re-paste receives the synthetic leading space", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = "/p\n".repeat(12).trimEnd(); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + + // Typing before the marker puts a word character right in front of + // it, so this re-paste gets the synthetic space and must still be + // recognized as identical. + editor.handleInput("\x01"); + editor.handleInput("word"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.strictEqual(editor.getText(), `word${paste}`); + }); + + it("does not expand when the re-paste genuinely carries the space the stored paste synthesized", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("word"); + // Stored as " /p..." with the synthetic-space flag set. + const paste = "/p\n".repeat(12).trimEnd(); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + // The new clipboard genuinely starts with " /p..." — different + // clipboard content even though the editor text would look equal. + editor.handleInput(`\x1b[200~ ${paste}\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("[paste #2")); + }); + it("treats paste marker as single unit for right arrow", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("A");