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/paste-collapse-cli-marker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Collapse large pastes in the input box to `[Pasted text #N +x lines]` so pasting long text no longer freezes the TUI.
5 changes: 5 additions & 0 deletions .changeset/paste-collapse-non-bracketed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

Coalesce large non-bracketed stdin paste batches into one paste event and fold them to `[Pasted text #N]` markers (800 chars / >10 newlines).
3 changes: 2 additions & 1 deletion apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ 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 PASTE_MARKER_RE =
/\[(?:Pasted text|paste) #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g;
const BRACKET_PASTE_START = '\u001B[200~';
const BRACKET_PASTE_END = '\u001B[201~';

Expand Down
25 changes: 13 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 @@ -479,11 +479,11 @@ describe('CustomEditor paste marker expansion', () => {
const longText = 'line\n'.repeat(15).trimEnd();
simulateLargePaste(editor, longText);

expect(editor.getText()).toMatch(/\[paste #1 \+15 lines\]/);
expect(editor.getText()).toMatch(/\[Pasted text #1 \+\d+ lines\]/);

simulateLargePaste(editor, 'anything');

expect(editor.getText()).not.toContain('[paste #');
expect(editor.getText()).not.toMatch(/\[(?:Pasted text|paste) #/);
expect(editor.getText()).toContain(longText);
});

Expand All @@ -495,14 +495,14 @@ describe('CustomEditor paste marker expansion', () => {
editor.handleInput('hello');

const textBefore = editor.getText();
expect(textBefore).toContain('[paste #1');
expect(textBefore).toContain('[Pasted text #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');
expect(editor.getText()).toContain('[Pasted text #1');
expect(editor.getText()).toContain('[Pasted text #2');
});

it('expands only the marker under cursor when multiple markers exist', () => {
Expand All @@ -513,10 +513,11 @@ describe('CustomEditor paste marker expansion', () => {
editor.handleInput(' ');
simulateLargePaste(editor, text2);

expect(editor.getText()).toContain('[paste #1');
expect(editor.getText()).toContain('[paste #2');
expect(editor.getText()).toContain('[Pasted text #1');
expect(editor.getText()).toContain('[Pasted text #2');

editor.setText('[paste #1 +15 lines] [paste #2 +15 lines]');
// Legacy marker strings must still expand (dual-format regex).
editor.setText('[paste #1 +14 lines] [paste #2 +14 lines]');

simulateLargePaste(editor, 'anything');

Expand All @@ -531,11 +532,11 @@ describe('CustomEditor paste marker expansion', () => {
const longText = 'line\n'.repeat(15).trimEnd();
simulateLargePaste(editor, longText);

expect(editor.getText()).toMatch(/\[paste #1/);
expect(editor.getText()).toMatch(/\[Pasted text #1/);

editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016');

expect(editor.getText()).not.toContain('[paste #');
expect(editor.getText()).not.toMatch(/\[(?:Pasted text|paste) #/);
expect(editor.getText()).toContain(longText);
});

Expand All @@ -545,15 +546,15 @@ describe('CustomEditor paste marker expansion', () => {
simulateLargePaste(editor, longText);

const markerText = editor.getText();
expect(markerText).toMatch(/\[paste #1/);
expect(markerText).toMatch(/\[Pasted text #1/);

simulateLargePaste(editor, 'anything');
expect(editor.getText()).toContain(longText);

editor.setText(markerText);

simulateLargePaste(editor, 'anything');
expect(editor.getText()).not.toContain('[paste #');
expect(editor.getText()).not.toMatch(/\[(?:Pasted text|paste) #/);
expect(editor.getText()).toContain(longText);
});

Expand Down
4 changes: 2 additions & 2 deletions packages/pi-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Minimal terminal UI framework with differential rendering and synchronized outpu

- **Differential Rendering**: Three-strategy rendering system that only updates what changed
- **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
- **Bracketed Paste Mode**: Handles large pastes correctly with markers for long pastes
- **Component-based**: Simple Component interface with render() method
- **Theme Support**: Components accept theme interfaces for customizable styling
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container
Expand Down Expand Up @@ -320,7 +320,7 @@ editor.getPaddingX(); // Get current padding
- Multi-line editing with word wrap
- Slash command autocomplete (type `/`)
- File path autocomplete (press `Tab`)
- Large paste handling (>10 lines creates `[paste #1 +50 lines]` marker)
- Large paste handling (>800 chars or >10 newlines creates `[Pasted text #1 +50 lines]` marker)
- Horizontal lines above/below editor
- Fake cursor rendering (hidden real cursor)

Expand Down
49 changes: 34 additions & 15 deletions packages/pi-tui/src/components/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,35 @@ import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "
const graphemeSegmenter = getGraphemeSegmenter();
const wordSegmenter = getWordSegmenter();

/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
/** Regex matching paste markers: Claude-style `[Pasted text #N …]` and legacy `[paste #N …]`. */
const PASTE_MARKER_REGEX =
/\[(?:Pasted text|paste) #(\d+)( (?:\+\d+ lines|\d+ chars))?\]/g;

/** Non-global version for single-segment testing. */
const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
const PASTE_MARKER_SINGLE = /^\[(?:Pasted text|paste) #(\d+)( (?:\+\d+ lines|\d+ chars))?\]$/;

/** Collapse pasted text into a marker above this character count (Claude Code uses 800). */
const PASTE_FOLD_CHAR_THRESHOLD = 800;
/** Collapse when newline count exceeds this (≈11+ lines of pasted text). */
const PASTE_FOLD_NEWLINE_THRESHOLD = 10;

/** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
function isPasteMarker(segment: string): boolean {
return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
}

/** Newline count for paste refs — `a\nb\nc` is 2 (Claude Code semantics). */
function getPastedTextRefNumLines(text: string): number {
return (text.match(/\r\n|\r|\n/g) || []).length;
}

function formatPastedTextRef(id: number, numLines: number): string {
if (numLines === 0) {
return `[Pasted text #${id}]`;
}
return `[Pasted text #${id} +${numLines} lines]`;
}

/**
* A segmenter that wraps Intl.Segmenter and merges graphemes that fall
* within paste markers into single atomic segments. This makes cursor
Expand All @@ -43,7 +61,10 @@ function segmentWithMarkers(
validIds: Set<number>,
): Iterable<Intl.SegmentData> {
// Fast path: no paste markers in the text or no valid IDs.
if (validIds.size === 0 || !text.includes("[paste #")) {
if (
validIds.size === 0 ||
(!text.includes("[paste #") && !text.includes("[Pasted text #"))
) {
return baseSegmenter.segment(text);
}

Expand Down Expand Up @@ -1081,7 +1102,10 @@ export class Editor implements Component, Focusable {
private expandPasteMarkers(text: string): string {
let result = text;
for (const [pasteId, pasteContent] of this.pastes) {
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
const markerRegex = new RegExp(
`\\[(?:Pasted text|paste) #${pasteId}( (?:\\+\\d+ lines|\\d+ chars))?\\]`,
"g",
);
result = result.replace(markerRegex, () => pasteContent);
}
return result;
Expand Down Expand Up @@ -1288,20 +1312,15 @@ export class Editor implements Component, Focusable {
// Split into lines to check for large paste
const pastedLines = filteredText.split("\n");

// Check if this is a large paste (> 10 lines or > 1000 characters)
// Fold large pastes into a marker so the input box stays one line
// (>800 chars, or 11+ lines — keep short multi-line pastes visible).
const totalChars = filteredText.length;
if (pastedLines.length > 10 || totalChars > 1000) {
// Store the paste and insert a marker
const numLines = getPastedTextRefNumLines(filteredText);
if (totalChars > PASTE_FOLD_CHAR_THRESHOLD || numLines > PASTE_FOLD_NEWLINE_THRESHOLD) {
this.pasteCounter++;
const pasteId = this.pasteCounter;
this.pastes.set(pasteId, filteredText);

// Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
const marker =
pastedLines.length > 10
? `[paste #${pasteId} +${pastedLines.length} lines]`
: `[paste #${pasteId} ${totalChars} chars]`;
this.insertTextAtCursorInternal(marker);
this.insertTextAtCursorInternal(formatPastedTextRef(pasteId, numLines));
return;
}

Expand Down
87 changes: 86 additions & 1 deletion packages/pi-tui/src/stdin-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ const ESC = "\x1b";
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";

/** Plain-text chunk size that is treated as a non-bracketed paste start (Claude Code uses 800). */
export const PASTE_CHUNK_THRESHOLD = 800;
/** Idle window to coalesce Node stdin paste batches into one paste event. */
export const PASTE_FLUSH_MS = 100;

/**
* First index of a byte that is an interactive keystroke, not paste content:
* an escape sequence (ESC), backspace (DEL), or any C0 control other than
* newline / CR / tab. Returns -1 when the whole chunk is plain paste text.
*/
function firstInteractiveByteIndex(str: string): number {
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code === 0x7f || (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)) {
return i;
}
}
return -1;
}

/**
* Check if a string is a complete escape sequence or needs more data
*/
Expand Down Expand Up @@ -278,6 +298,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
private pasteMode: boolean = false;
private pasteBuffer: string = "";
private pendingKittyPrintableCodepoint: number | undefined;
/** Coalesces large non-bracketed paste batches (Node often splits them). */
private nonBracketedPastePending = false;
private nonBracketedPasteBuffer = "";
private nonBracketedPasteTimeout: ReturnType<typeof setTimeout> | null = null;

constructor(options: StdinBufferOptions = {}) {
super();
Expand Down Expand Up @@ -305,11 +329,29 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
str = data;
}

if (str.length === 0 && this.buffer.length === 0) {
if (str.length === 0 && this.buffer.length === 0 && !this.nonBracketedPastePending) {
this.emitDataSequence("");
return;
}

// Continue a non-bracketed paste coalescing window. Only coalesce chunks
// that are still plain paste content; an interactive keystroke (arrow /
// backspace / Ctrl / a fresh bracketed paste, all ESC- or C0-led) flushes
// the pending paste first so it is dispatched instead of being swallowed.
if (this.nonBracketedPastePending) {
const interactiveAt = firstInteractiveByteIndex(str);
if (interactiveAt !== -1) {
if (interactiveAt > 0) {
this.nonBracketedPasteBuffer += str.slice(0, interactiveAt);
}
this.flushNonBracketedPaste();
this.process(str.slice(interactiveAt));
return;
}
this.appendNonBracketedPaste(str);
return;
Comment on lines +351 to +352

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 Do not append post-paste key sequences to the paste buffer

When a large non-bracketed paste is pending, this branch appends every following stdin chunk for 100 ms, including control/escape sequences that are user keystrokes rather than paste continuations. If the user immediately presses Enter to submit, or an arrow/backspace key after pasting >800 chars, that key is swallowed into the synthetic paste event instead of being dispatched; escape sequences can also be rewrapped as bracketed paste content and leak printable tails like [D into the editor. Flush the pending paste before handling non-plain key sequences, or only coalesce chunks that are still plain paste content.

Useful? React with 👍 / 👎.

}

this.buffer += str;

if (this.pasteMode) {
Expand Down Expand Up @@ -368,6 +410,17 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
return;
}

// Large plain-text stdin batches without bracketed paste markers: coalesce
// into one paste event so the editor can collapse to a marker instead of
// inserting character-by-character (which freezes the TUI).
if (!this.buffer.includes(ESC) && this.buffer.length > PASTE_CHUNK_THRESHOLD) {
const pending = this.buffer;
this.buffer = "";
this.pendingKittyPrintableCodepoint = undefined;
this.appendNonBracketedPaste(pending);
return;
}

const result = extractCompleteSequences(this.buffer);
this.buffer = result.remainder;

Expand All @@ -386,6 +439,32 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
}
}

private appendNonBracketedPaste(chunk: string): void {
this.nonBracketedPastePending = true;
this.nonBracketedPasteBuffer += chunk;
if (this.nonBracketedPasteTimeout) {
clearTimeout(this.nonBracketedPasteTimeout);
}
this.nonBracketedPasteTimeout = setTimeout(() => {
this.nonBracketedPasteTimeout = null;
this.flushNonBracketedPaste();
}, PASTE_FLUSH_MS);
}

private flushNonBracketedPaste(): void {
if (this.nonBracketedPasteTimeout) {
clearTimeout(this.nonBracketedPasteTimeout);
this.nonBracketedPasteTimeout = null;
}
const content = this.nonBracketedPasteBuffer;
this.nonBracketedPastePending = false;
this.nonBracketedPasteBuffer = "";
this.pendingKittyPrintableCodepoint = undefined;
if (content.length > 0) {
this.emit("paste", content);
}
}

private emitDataSequence(sequence: string): void {
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
if (rawCodepoint !== undefined && rawCodepoint === this.pendingKittyPrintableCodepoint) {
Expand Down Expand Up @@ -418,9 +497,15 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
clearTimeout(this.timeout);
this.timeout = null;
}
if (this.nonBracketedPasteTimeout) {
clearTimeout(this.nonBracketedPasteTimeout);
this.nonBracketedPasteTimeout = null;
}
this.buffer = "";
this.pasteMode = false;
this.pasteBuffer = "";
this.nonBracketedPastePending = false;
this.nonBracketedPasteBuffer = "";
this.pendingKittyPrintableCodepoint = undefined;
}

Expand Down
Loading