Skip to content
Open
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/tui-paste-marker-second-paste-expand.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 59 additions & 34 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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+<LETTER>` with caps_lock into `ctrl+<letter>` without caps_lock.
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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<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);
// 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 {
Expand Down Expand Up @@ -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
Comment thread
kimi-agent-bot marked this conversation as resolved.
) {
Comment thread
kimi-agent-bot marked this conversation as resolved.
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
Expand All @@ -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) {
Comment thread
kimi-agent-bot marked this conversation as resolved.
// 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) {
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/constant/paste.ts
Original file line number Diff line number Diff line change
@@ -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;
178 changes: 166 additions & 12 deletions apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
});

Expand All @@ -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();
Expand Down
Loading
Loading