Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/expand-paste-marker.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 66 additions & 11 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+<LETTER>` with caps_lock into `ctrl+<letter>` without caps_lock.
Expand Down Expand Up @@ -118,6 +122,9 @@ export class CustomEditor extends Editor {
*/
public onPasteImage?: () => Promise<boolean>;

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
Expand All @@ -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<number, string> }).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 (
Expand Down Expand Up @@ -190,24 +221,48 @@ 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;
Comment on lines +229 to +233

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve input after the bracketed paste terminator

When consumingPaste is true and the terminal coalesces the bracketed-paste terminator with following input in the same data event, e.g. chunk\x1b[201~x after a marker expansion, this branch clears the consume state but still returns without replaying the bytes after \x1b[201~. That drops the user's first keystroke or escape sequence after the paste instead of handling it normally.

Useful? React with 👍 / 👎.

}

// 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
// Alt-V there. Everywhere else Ctrl-V pastes. When the host
// 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'))) {
Expand Down
123 changes: 123 additions & 0 deletions apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading