From b664e006134da9d95ca6c1dfc4be4bda2cd6be94 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 19:39:46 +0800 Subject: [PATCH 1/4] feat(tui): expand paste markers on second paste When the cursor sits on a folded paste marker (e.g. `[paste #1 +15 lines]`) and the user pastes again (Ctrl-V or bracketed paste), the marker is expanded back to its original content instead of inserting new clipboard data. --- .../tui/components/editor/custom-editor.ts | 75 +++++++++++++--- .../components/editor/custom-editor.test.ts | 87 +++++++++++++++++++ 2 files changed, 151 insertions(+), 11 deletions(-) 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 28a0bb6bac..34ce62ee21 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -11,6 +11,10 @@ import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; // 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. @@ -118,6 +122,8 @@ export class CustomEditor extends Editor { */ public onPasteImage?: () => Promise; + private consumingPaste = false; + /** * `colors` is the live `ColorPalette` reference — the host mutates it * in place on theme switch (`Object.assign(state.theme.colors, ...)`), so @@ -140,6 +146,31 @@ export class CustomEditor extends Editor { super(tui, createEditorTheme(colors), { paddingX: 4 }); } + 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); + pastes.delete(pasteId); + this.setText(newText); + return true; + } + return false; + } + private hasAutocompleteActivity(): boolean { const autocomplete = this as unknown as AutocompleteInternals; return ( @@ -190,6 +221,25 @@ export class CustomEditor extends Editor { if (isKeyRelease(normalized)) { return; } + + // When a paste marker was just expanded, discard the trailing bracketed + // paste data that the terminal sends alongside the Ctrl-V keystroke. + if (this.consumingPaste) { + if (normalized.includes(BRACKET_PASTE_END)) { + this.consumingPaste = false; + } + 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_END)) { + this.consumingPaste = true; + } + return; + } + // 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 @@ -197,17 +247,20 @@ export class CustomEditor extends Editor { // reports no image available, we fall through to pi-tui's // normal paste path so text from the clipboard still works. const pasteKey = process.platform === 'win32' ? 'alt+v' : Key.ctrl('v'); - if (matchesKey(normalized, pasteKey) && this.onPasteImage !== undefined) { - const handler = this.onPasteImage; - void handler().then((handled) => { - if (!handled) { - this.onTextPaste?.(); - // No image on the clipboard — forward the original keystroke - // through the base handler so a textual clipboard still works. - super.handleInput.call(this, normalized); - } - }); - return; + if (matchesKey(normalized, pasteKey)) { + if (this.expandPasteMarkerAtCursor()) { + return; + } + if (this.onPasteImage !== undefined) { + const handler = this.onPasteImage; + void handler().then((handled) => { + if (!handled) { + this.onTextPaste?.(); + super.handleInput.call(this, normalized); + } + }); + return; + } } if (matchesKey(normalized, Key.ctrl('d'))) { 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 f80a52fdcf..6286bbbd4c 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 @@ -84,6 +84,93 @@ describe('CustomEditor Kitty key release handling', () => { }); }); +describe('CustomEditor paste marker expansion', () => { + const PASTE_START = '\x1b[200~'; + const PASTE_END = '\x1b[201~'; + + function simulateLargePaste(editor: CustomEditor, content: string): void { + editor.handleInput(`${PASTE_START}${content}${PASTE_END}`); + } + + it('expands paste marker when bracketed paste arrives while cursor is on 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()).not.toContain('[paste #'); + expect(editor.getText()).toContain(longText); + }); + + it('does not expand when cursor is not on a paste marker', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + editor.handleInput('hello'); + + const textBefore = editor.getText(); + expect(textBefore).toContain('[paste #1'); + expect(textBefore).toContain('hello'); + + const anotherLong = 'other\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, anotherLong); + + expect(editor.getText()).toContain('[paste #1'); + expect(editor.getText()).toContain('[paste #2'); + }); + + it('expands only the marker under cursor when multiple markers exist', () => { + const editor = makeEditor(); + const text1 = 'first\n'.repeat(15).trimEnd(); + const text2 = 'second\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, text1); + editor.handleInput(' '); + simulateLargePaste(editor, text2); + + expect(editor.getText()).toContain('[paste #1'); + expect(editor.getText()).toContain('[paste #2'); + + editor.setText('[paste #1 +15 lines] [paste #2 +15 lines]'); + + simulateLargePaste(editor, 'anything'); + + expect(editor.getText()).toContain('[paste #1'); + expect(editor.getText()).not.toContain('[paste #2'); + expect(editor.getText()).toContain(text2); + }); + + it('handles Ctrl+V expansion when cursor is on marker', () => { + const editor = makeEditor(); + editor.onPasteImage = vi.fn(async () => false); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + expect(editor.getText()).toMatch(/\[paste #1/); + + editor.handleInput('\x16'); + + expect(editor.getText()).not.toContain('[paste #'); + expect(editor.getText()).toContain(longText); + }); + + it('suppresses multi-chunk bracketed paste data after marker expansion', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + editor.handleInput(`${PASTE_START}chunk1`); + editor.handleInput(`chunk2${PASTE_END}`); + + expect(editor.getText()).not.toContain('chunk1'); + expect(editor.getText()).not.toContain('chunk2'); + expect(editor.getText()).toContain(longText); + }); +}); + describe('CustomEditor shortcut telemetry hooks', () => { it('reports newline shortcuts, including Ctrl-J, before delegating to the base editor', () => { const editor = makeEditor(); From fbb734da17acc287c0fc24d8d7583582551d6e2d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 19:40:35 +0800 Subject: [PATCH 2/4] chore: add changeset for paste marker expansion --- .changeset/expand-paste-marker.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/expand-paste-marker.md diff --git a/.changeset/expand-paste-marker.md b/.changeset/expand-paste-marker.md new file mode 100644 index 0000000000..2828bde5e6 --- /dev/null +++ b/.changeset/expand-paste-marker.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Expand folded paste markers on second paste. When the cursor is on a paste marker (e.g. `[paste #1 +15 lines]`) and the user pastes again, the marker expands back to the original content instead of inserting new clipboard data. From b99513a88873dccfe7546a886459f221f821a8ad Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 19:59:04 +0800 Subject: [PATCH 3/4] fix(tui): preserve paste content after marker expansion for undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop deleting the paste entry from the Map after expansion so that undo → re-expand still works. --- .../src/tui/components/editor/custom-editor.ts | 1 - .../components/editor/custom-editor.test.ts | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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 34ce62ee21..7585ad9992 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -164,7 +164,6 @@ export class CustomEditor extends Editor { 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); - pastes.delete(pasteId); this.setText(newText); return true; } 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 6286bbbd4c..6d699ebd4e 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 @@ -157,6 +157,24 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toContain(longText); }); + it('can re-expand after undo restores the marker', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + const markerText = editor.getText(); + expect(markerText).toMatch(/\[paste #1/); + + simulateLargePaste(editor, 'anything'); + expect(editor.getText()).toContain(longText); + + editor.setText(markerText); + + simulateLargePaste(editor, 'anything'); + expect(editor.getText()).not.toContain('[paste #'); + expect(editor.getText()).toContain(longText); + }); + it('suppresses multi-chunk bracketed paste data after marker expansion', () => { const editor = makeEditor(); const longText = 'line\n'.repeat(15).trimEnd(); From 705466b6e5a01b364316aaf4e57fcceb1e28ab40 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 20:05:53 +0800 Subject: [PATCH 4/4] fix(tui): buffer consumed paste data to handle split end sequences Accumulate chunks while consuming discarded paste data so a split ESC[201~ across chunks still resets consumingPaste. --- .../src/tui/components/editor/custom-editor.ts | 5 ++++- .../components/editor/custom-editor.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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 7585ad9992..3ceff7a9d3 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -123,6 +123,7 @@ export class CustomEditor extends Editor { public onPasteImage?: () => Promise; private consumingPaste = false; + private consumeBuffer = ''; /** * `colors` is the live `ColorPalette` reference — the host mutates it @@ -224,8 +225,10 @@ export class CustomEditor extends Editor { // When a paste marker was just expanded, discard the trailing bracketed // paste data that the terminal sends alongside the Ctrl-V keystroke. if (this.consumingPaste) { - if (normalized.includes(BRACKET_PASTE_END)) { + this.consumeBuffer += normalized; + if (this.consumeBuffer.includes(BRACKET_PASTE_END)) { this.consumingPaste = false; + this.consumeBuffer = ''; } return; } 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 6d699ebd4e..6545af2665 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 @@ -187,6 +187,24 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).not.toContain('chunk2'); expect(editor.getText()).toContain(longText); }); + + it('handles paste-end sequence split across chunks', () => { + const editor = makeEditor(); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); + + // Split: PASTE_START in chunk 1, paste-end split across chunk 2 and 3 + editor.handleInput(`${PASTE_START}data`); + editor.handleInput('\x1b[20'); + editor.handleInput('1~'); + + expect(editor.getText()).toContain(longText); + expect(editor.getText()).not.toContain('data'); + + // Verify editor is not stuck — next keystrokes should work normally + editor.handleInput('x'); + expect(editor.getText()).toContain('x'); + }); }); describe('CustomEditor shortcut telemetry hooks', () => {