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. 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..3ceff7a9d3 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,9 @@ export class CustomEditor extends Editor { */ public onPasteImage?: () => Promise; + private consumingPaste = false; + private consumeBuffer = ''; + /** * `colors` is the live `ColorPalette` reference — the host mutates it * in place on theme switch (`Object.assign(state.theme.colors, ...)`), so @@ -140,6 +147,30 @@ 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); + this.setText(newText); + return true; + } + return false; + } + private hasAutocompleteActivity(): boolean { const autocomplete = this as unknown as AutocompleteInternals; return ( @@ -190,6 +221,27 @@ 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) { + this.consumeBuffer += normalized; + if (this.consumeBuffer.includes(BRACKET_PASTE_END)) { + this.consumingPaste = false; + this.consumeBuffer = ''; + } + 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 +249,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..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 @@ -84,6 +84,129 @@ 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('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(); + 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); + }); + + 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', () => { it('reports newline shortcuts, including Ctrl-J, before delegating to the base editor', () => { const editor = makeEditor();