From 21443495a4898f41ea8e17a33700029652582378 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 12:38:02 +0000 Subject: [PATCH 01/10] fix(tui): expand paste markers only when the identical content is re-pasted Co-authored-by: liruifengv --- .../tui-paste-marker-second-paste-expand.md | 5 ++ .../tui/components/editor/custom-editor.ts | 52 ------------- .../components/editor/custom-editor.test.ts | 37 ++++++--- packages/pi-tui/src/components/editor.ts | 53 ++++++++++++- packages/pi-tui/test/editor.test.ts | 78 +++++++++++++++++++ 5 files changed, 159 insertions(+), 66 deletions(-) create mode 100644 .changeset/tui-paste-marker-second-paste-expand.md diff --git a/.changeset/tui-paste-marker-second-paste-expand.md b/.changeset/tui-paste-marker-second-paste-expand.md new file mode 100644 index 00000000000..7a0e585a80b --- /dev/null +++ b/.changeset/tui-paste-marker-second-paste-expand.md @@ -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. 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 2a280209325..55957467e37 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -24,10 +24,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+` with caps_lock into `ctrl+` without caps_lock. @@ -160,8 +156,6 @@ export class CustomEditor extends Editor { */ public onPasteImage?: () => Promise; - private consumingPaste = false; - private consumeBuffer = ''; /** Serialize paste callbacks so Enter/typing cannot overtake an image paste. */ private pasteInFlight = false; private readonly pasteInputQueue: string[] = []; @@ -230,32 +224,6 @@ 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 }).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; - } - return false; - } - private hasAutocompleteActivity(): boolean { const autocomplete = this as unknown as AutocompleteInternals; return ( @@ -382,26 +350,6 @@ 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. - 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 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 6b68d83e197..fc4d2030ad1 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 @@ -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(); @@ -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'); @@ -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); }); @@ -580,8 +593,8 @@ 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'); diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 276cac7e7a1..df441b9f49a 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1132,6 +1132,51 @@ export class Editor implements Component, Focusable { return this.expandPasteMarkers(this.state.lines.join("\n")); } + /** + * Expand the paste marker under the cursor, replacing it with its stored + * content. The paste registry is preserved so undo restores both the marker + * text and its entry. Returns false when the cursor is not on a live marker. + */ + expandPasteMarkerAtCursor(): boolean { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + PASTE_MARKER_REGEX.lastIndex = 0; + for (const match of currentLine.matchAll(PASTE_MARKER_REGEX)) { + const start = match.index; + const end = start + match[0].length; + if (this.state.cursorCol < start || this.state.cursorCol > end) continue; + + const pasteId = Number(match[1]); + const content = this.pastes.get(pasteId); + if (content === undefined) return false; + + const text = this.getText(); + const offset = + this.state.lines.slice(0, this.state.cursorLine).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, { preservePasteRegistry: true }); + return true; + } + return false; + } + + /** + * Second-paste gesture: pasting content identical to what the marker under + * the cursor holds expands that marker instead of inserting a duplicate. + */ + private expandMarkerForIdenticalPaste(filteredText: string): boolean { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + PASTE_MARKER_REGEX.lastIndex = 0; + for (const match of currentLine.matchAll(PASTE_MARKER_REGEX)) { + const start = match.index; + const end = start + match[0].length; + if (this.state.cursorCol < start || this.state.cursorCol > end) continue; + if (this.pastes.get(Number(match[1])) !== filteredText) return false; + return this.expandPasteMarkerAtCursor(); + } + return false; + } + getLines(): string[] { return [...this.state.lines]; } @@ -1302,8 +1347,6 @@ export class Editor implements Component, Focusable { this.exitHistoryBrowsing(); this.lastAction = null; - this.pushUndoSnapshot(); - // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode // control bytes inside bracketed paste as CSI-u Ctrl+ sequences // (ESC [ ; 5 u). Decode those back to their literal byte so the @@ -1335,6 +1378,12 @@ export class Editor implements Component, Focusable { } } + // Re-pasting a marker's exact stored content onto it expands the marker + // (second-paste gesture) instead of inserting a duplicate. + if (this.expandMarkerForIdenticalPaste(filteredText)) return; + + this.pushUndoSnapshot(); + // Split into lines to check for large paste const pastedLines = filteredText.split("\n"); diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 7ed25e0241c..250456c8bdd 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4053,6 +4053,84 @@ describe("Editor component", () => { assert.match(text, /\[paste #\d+ \+\d+ lines\]/); }); + it("expands the marker when the identical content is pasted onto it again", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1 \+12 lines\]/); + + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.strictEqual(editor.getText(), paste); + }); + + it("pastes different content normally even when the cursor is on a marker", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput(`\x1b[200~${bigPaste("alpha")}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + editor.handleInput(`\x1b[200~different\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("different")); + }); + + it("creates a second marker for the identical content when the cursor is not on the first marker", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + editor.handleInput("x"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("[paste #2")); + }); + + it("expands the marker when the identical paste arrives split across chunks", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + + const splitAt = Math.floor(paste.length / 2); + editor.handleInput(`\x1b[200~${paste.slice(0, splitAt)}`); + editor.handleInput(`${paste.slice(splitAt)}\x1b[201~`); + assert.strictEqual(editor.getText(), paste); + }); + + it("expandPasteMarkerAtCursor replaces the marker with its stored content", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + assert.strictEqual(editor.expandPasteMarkerAtCursor(), true); + assert.strictEqual(editor.getText(), paste); + }); + + it("expandPasteMarkerAtCursor returns false when the cursor is not on a marker", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("plain"); + assert.strictEqual(editor.expandPasteMarkerAtCursor(), false); + assert.strictEqual(editor.getText(), "plain"); + }); + + it("undo after a second-paste expansion restores the marker and its registry entry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.strictEqual(editor.getText(), paste); + + editor.handleInput("\x1b[45;5u"); // undo: restores marker text + assert.ok(editor.getText().includes("[paste #1")); + editor.handleInput("\r"); + assert.strictEqual(submitted, paste); + }); + it("treats paste marker as single unit for right arrow", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("A"); From 93d86123089a6275a6ea934f3cd70d367a1f698b Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 12:53:59 +0000 Subject: [PATCH 02/10] fix(tui): tolerate synthetic path-spacing in paste identity check and register the divergence Co-authored-by: liruifengv --- packages/pi-tui/AGENTS.md | 1 + packages/pi-tui/src/components/editor.ts | 7 ++++++- packages/pi-tui/test/editor.test.ts | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 3ee3f9a0033..94393b3d566 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,6 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content while preserving the paste registry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check ignores at most one synthetic leading space from the path-spacing rule). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns false when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", and "expands the marker on re-paste when the stored content carries a synthetic leading space" in `test/editor.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index df441b9f49a..e418360db1b 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1171,7 +1171,12 @@ export class Editor implements Component, Focusable { const start = match.index; const end = start + match[0].length; if (this.state.cursorCol < start || this.state.cursorCol > end) continue; - if (this.pastes.get(Number(match[1])) !== filteredText) return false; + const stored = this.pastes.get(Number(match[1])); + // The path-spacing rule may have prepended a synthetic space to either + // side (first paste after a word character vs. re-paste after "]"), so + // the identity check ignores at most one leading space. + if (stored === undefined || stored.replace(/^ /, "") !== filteredText.replace(/^ /, "")) + return false; return this.expandPasteMarkerAtCursor(); } return false; diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 250456c8bdd..7e330a7644b 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4131,6 +4131,21 @@ describe("Editor component", () => { assert.strictEqual(submitted, paste); }); + it("expands the marker on re-paste when the stored content carries a synthetic leading space", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("word"); + // First paste starts with "/" and follows a word character, so the + // path-spacing rule prepends a synthetic space to the stored content. + const paste = "/p\n".repeat(12).trimEnd(); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1 \+12 lines\]/); + + // The re-paste lands after "]", gets no synthetic space, and must + // still be recognized as identical. + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.strictEqual(editor.getText(), `word ${paste}`); + }); + it("treats paste marker as single unit for right arrow", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("A"); From c22f9f551075e29d798ecfe8a626d39980a7bbb5 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 13:09:32 +0000 Subject: [PATCH 03/10] fix(tui): narrow paste identity tolerance to path-rule-synthesizable spacing Co-authored-by: liruifengv --- packages/pi-tui/AGENTS.md | 2 +- packages/pi-tui/src/components/editor.ts | 20 ++++++++++++++------ packages/pi-tui/test/editor.test.ts | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 94393b3d566..ae429114342 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,7 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. -9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content while preserving the paste registry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check ignores at most one synthetic leading space from the path-spacing rule). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns false when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", and "expands the marker on re-paste when the stored content carries a synthetic leading space" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content while preserving the paste registry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check only tolerates a single leading space the path-spacing rule could have synthesized — the unspaced side must start with a path character — so pastes genuinely differing by a leading space never match). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns false when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", and "does not expand when the re-paste differs by a genuine leading space" in `test/editor.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index e418360db1b..d9db3b34611 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1171,17 +1171,25 @@ export class Editor implements Component, Focusable { const start = match.index; const end = start + match[0].length; if (this.state.cursorCol < start || this.state.cursorCol > end) continue; - const stored = this.pastes.get(Number(match[1])); - // The path-spacing rule may have prepended a synthetic space to either - // side (first paste after a word character vs. re-paste after "]"), so - // the identity check ignores at most one leading space. - if (stored === undefined || stored.replace(/^ /, "") !== filteredText.replace(/^ /, "")) - return false; + if (!this.isSamePasteContent(this.pastes.get(Number(match[1])), filteredText)) return false; return this.expandPasteMarkerAtCursor(); } return false; } + /** + * Identity for the second-paste gesture: exact match, or one side carries a + * single leading space the path-spacing rule could have synthesized — the + * unspaced side must start with a path character, so pastes that genuinely + * differ by a leading space never count as identical. + */ + private isSamePasteContent(stored: string | undefined, pasted: string): boolean { + if (stored === undefined) return false; + if (stored === pasted) return true; + const [spaced, plain] = stored.startsWith(" ") ? [stored, pasted] : [pasted, stored]; + return spaced === ` ${plain}` && /^[/~.]/.test(plain); + } + getLines(): string[] { return [...this.state.lines]; } diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 7e330a7644b..9a485d8e425 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4146,6 +4146,20 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), `word ${paste}`); }); + it("does not expand when the re-paste differs by a genuine leading space", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + // A paste that genuinely differs by one leading space is new content, + // not the expansion gesture. + editor.handleInput(`\x1b[200~ ${paste}\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("[paste #2")); + }); + it("treats paste marker as single unit for right arrow", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("A"); From c31c3a9b135010e6a7dff63b19a5ec7b8fbd3a42 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 13:27:49 +0000 Subject: [PATCH 04/10] fix(tui): record synthesized path spacing instead of inferring paste identity Co-authored-by: liruifengv --- packages/pi-tui/AGENTS.md | 2 +- packages/pi-tui/src/components/editor.ts | 40 +++++++++++++++++------- packages/pi-tui/test/editor.test.ts | 30 ++++++++++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index ae429114342..5b62d33fcef 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,7 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. -9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content while preserving the paste registry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check only tolerates a single leading space the path-spacing rule could have synthesized — the unspaced side must start with a path character — so pastes genuinely differing by a leading space never match). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns false when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", and "does not expand when the re-paste differs by a genuine leading space" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content while preserving the paste registry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check compares against the genuine clipboard text — whether a leading space was actually synthesized by the path-spacing rule is recorded per paste id in `pasteSyntheticSpacing` and maintained across marker renumbering, registry clears, and undo snapshots — never inferred from the two values). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns false when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", "does not expand when the re-paste differs by a genuine leading space", "does not expand when a leading space is genuine on the re-paste of path-starting content", and "expands the marker when the re-paste receives the synthetic leading space" in `test/editor.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index d9db3b34611..1855bea68b9 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -229,6 +229,7 @@ interface EditorSnapshot { state: EditorState; pastes: Map; pasteCounter: number; + pasteSyntheticSpacing: Set; } interface LayoutLine { @@ -328,6 +329,8 @@ export class Editor implements Component, Focusable { // Paste tracking for large pastes private pastes: Map = new Map(); private pasteCounter: number = 0; + /** Paste ids whose stored content carries a synthetic leading space added by the path-spacing rule. */ + private pasteSyntheticSpacing: Set = new Set(); // Bracketed paste mode buffering private pasteBuffer: string = ""; @@ -1164,30 +1167,32 @@ export class Editor implements Component, Focusable { * Second-paste gesture: pasting content identical to what the marker under * the cursor holds expands that marker instead of inserting a duplicate. */ - private expandMarkerForIdenticalPaste(filteredText: string): boolean { + private expandMarkerForIdenticalPaste(filteredText: string, hadSyntheticSpace: boolean): boolean { const currentLine = this.state.lines[this.state.cursorLine] || ""; PASTE_MARKER_REGEX.lastIndex = 0; for (const match of currentLine.matchAll(PASTE_MARKER_REGEX)) { const start = match.index; const end = start + match[0].length; if (this.state.cursorCol < start || this.state.cursorCol > end) continue; - if (!this.isSamePasteContent(this.pastes.get(Number(match[1])), filteredText)) return false; + if (!this.isSamePasteContent(Number(match[1]), filteredText, hadSyntheticSpace)) return false; return this.expandPasteMarkerAtCursor(); } return false; } /** - * Identity for the second-paste gesture: exact match, or one side carries a - * single leading space the path-spacing rule could have synthesized — the - * unspaced side must start with a path character, so pastes that genuinely - * differ by a leading space never count as identical. + * Identity for the second-paste gesture. Only spacing the path-spacing rule + * actually synthesized may differ between the two sides — recorded per paste + * id at store time for the stored side, known locally for the incoming + * paste — so pastes that genuinely differ by a leading space never match. */ - private isSamePasteContent(stored: string | undefined, pasted: string): boolean { + private isSamePasteContent(pasteId: number, pasted: string, pastedHadSyntheticSpace: boolean): boolean { + const stored = this.pastes.get(pasteId); if (stored === undefined) return false; if (stored === pasted) return true; - const [spaced, plain] = stored.startsWith(" ") ? [stored, pasted] : [pasted, stored]; - return spaced === ` ${plain}` && /^[/~.]/.test(plain); + if (this.pasteSyntheticSpacing.has(pasteId) && stored.slice(1) === pasted) return true; + if (pastedHadSyntheticSpace && stored === pasted.slice(1)) return true; + return false; } getLines(): string[] { @@ -1210,6 +1215,7 @@ export class Editor implements Component, Focusable { if (!options?.preservePasteRegistry) { this.pastes.clear(); this.pasteCounter = 0; + this.pasteSyntheticSpacing.clear(); } this.setTextInternal(normalized); } @@ -1383,17 +1389,19 @@ export class Editor implements Component, Focusable { // If pasting a file path (starts with /, ~, or .) and the character before // the cursor is a word character, prepend a space for better readability + let hadSyntheticSpace = false; if (/^[/~.]/.test(filteredText)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : ""; if (charBeforeCursor && /\w/.test(charBeforeCursor)) { filteredText = ` ${filteredText}`; + hadSyntheticSpace = true; } } // Re-pasting a marker's exact stored content onto it expands the marker // (second-paste gesture) instead of inserting a duplicate. - if (this.expandMarkerForIdenticalPaste(filteredText)) return; + if (this.expandMarkerForIdenticalPaste(filteredText, hadSyntheticSpace)) return; this.pushUndoSnapshot(); @@ -1407,6 +1415,7 @@ export class Editor implements Component, Focusable { this.pasteCounter++; const pasteId = this.pasteCounter; this.pastes.set(pasteId, filteredText); + if (hadSyntheticSpace) this.pasteSyntheticSpacing.add(pasteId); // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]" const marker = @@ -1470,6 +1479,7 @@ export class Editor implements Component, Focusable { this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.pastes.clear(); this.pasteCounter = 0; + this.pasteSyntheticSpacing.clear(); this.exitHistoryBrowsing(); this.scrollOffset = 0; this.undoStack.clear(); @@ -1500,6 +1510,7 @@ export class Editor implements Component, Focusable { // This contains the id part e.g 4 from [paste #4 +123 lines] const targetId = Number(isPastedSegmented[1]); this.pastes.delete(targetId); + this.pasteSyntheticSpacing.delete(targetId); this.pasteCounter--; // Shift registry entries down in ascending id order, independent @@ -1509,6 +1520,7 @@ export class Editor implements Component, Focusable { for (const id of higherIds) { this.pastes.set(id - 1, this.pastes.get(id)!); this.pastes.delete(id); + if (this.pasteSyntheticSpacing.delete(id)) this.pasteSyntheticSpacing.add(id - 1); } // Renumber markers with ids greater than the removed one. @@ -2216,7 +2228,12 @@ export class Editor implements Component, Focusable { } private pushUndoSnapshot(): void { - this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter }); + this.undoStack.push({ + state: this.state, + pastes: this.pastes, + pasteCounter: this.pasteCounter, + pasteSyntheticSpacing: this.pasteSyntheticSpacing, + }); } private undo(): void { @@ -2226,6 +2243,7 @@ export class Editor implements Component, Focusable { Object.assign(this.state, snapshot.state); this.pastes = snapshot.pastes; this.pasteCounter = snapshot.pasteCounter; + this.pasteSyntheticSpacing = snapshot.pasteSyntheticSpacing; this.lastAction = null; this.preferredVisualCol = null; if (this.onChange) { diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 9a485d8e425..1cdb7d54927 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4160,6 +4160,36 @@ describe("Editor component", () => { assert.ok(text.includes("[paste #2")); }); + it("does not expand when a leading space is genuine on the re-paste of path-starting content", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + // Pasted into the empty editor: no word character before the cursor, + // so the stored content carries no synthetic space. + const paste = "/p\n".repeat(12).trimEnd(); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + // A genuine leading space makes this different content, even though + // the unspaced side starts with a path character. + editor.handleInput(`\x1b[200~ ${paste}\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("[paste #2")); + }); + + it("expands the marker when the re-paste receives the synthetic leading space", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = "/p\n".repeat(12).trimEnd(); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + + // Typing before the marker puts a word character right in front of + // it, so this re-paste gets the synthetic space and must still be + // recognized as identical. + editor.handleInput("\x01"); + editor.handleInput("word"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.strictEqual(editor.getText(), `word${paste}`); + }); + it("treats paste marker as single unit for right arrow", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("A"); From 84352d0dd1dbbe4de929f81eb20e54967d850e4a Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 13:47:06 +0000 Subject: [PATCH 05/10] fix(tui): compare paste identity on canonical clipboard text Co-authored-by: liruifengv --- packages/pi-tui/AGENTS.md | 2 +- packages/pi-tui/src/components/editor.ts | 16 ++++++++-------- packages/pi-tui/test/editor.test.ts | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 5b62d33fcef..1c0ee8110bf 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,7 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. -9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content while preserving the paste registry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check compares against the genuine clipboard text — whether a leading space was actually synthesized by the path-spacing rule is recorded per paste id in `pasteSyntheticSpacing` and maintained across marker renumbering, registry clears, and undo snapshots — never inferred from the two values). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns false when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", "does not expand when the re-paste differs by a genuine leading space", "does not expand when a leading space is genuine on the re-paste of path-starting content", and "expands the marker when the re-paste receives the synthetic leading space" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content while preserving the paste registry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check compares against the genuine clipboard text — whether a leading space was actually synthesized by the path-spacing rule is recorded per paste id in `pasteSyntheticSpacing` and maintained across marker renumbering, registry clears, and undo snapshots — never inferred from the two values). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns false when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", "does not expand when the re-paste differs by a genuine leading space", "does not expand when a leading space is genuine on the re-paste of path-starting content", "expands the marker when the re-paste receives the synthetic leading space", and "does not expand when the re-paste genuinely carries the space the stored paste synthesized" in `test/editor.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 1855bea68b9..79f38df831e 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1181,18 +1181,18 @@ export class Editor implements Component, Focusable { } /** - * Identity for the second-paste gesture. Only spacing the path-spacing rule - * actually synthesized may differ between the two sides — recorded per paste - * id at store time for the stored side, known locally for the incoming - * paste — so pastes that genuinely differ by a leading space never match. + * Identity for the second-paste gesture, compared on the genuine clipboard + * text of both sides: a leading space is stripped only when the synthetic + * flag says the path-spacing rule produced it (recorded per paste id at + * store time, known locally for the incoming paste), so a paste that + * genuinely carries that space never counts as identical. */ private isSamePasteContent(pasteId: number, pasted: string, pastedHadSyntheticSpace: boolean): boolean { const stored = this.pastes.get(pasteId); if (stored === undefined) return false; - if (stored === pasted) return true; - if (this.pasteSyntheticSpacing.has(pasteId) && stored.slice(1) === pasted) return true; - if (pastedHadSyntheticSpace && stored === pasted.slice(1)) return true; - return false; + const storedCanonical = this.pasteSyntheticSpacing.has(pasteId) ? stored.slice(1) : stored; + const pastedCanonical = pastedHadSyntheticSpace ? pasted.slice(1) : pasted; + return storedCanonical === pastedCanonical; } getLines(): string[] { diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 1cdb7d54927..34291d72adf 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4190,6 +4190,22 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), `word${paste}`); }); + it("does not expand when the re-paste genuinely carries the space the stored paste synthesized", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("word"); + // Stored as " /p..." with the synthetic-space flag set. + const paste = "/p\n".repeat(12).trimEnd(); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + assert.match(editor.getText(), /\[paste #1/); + + // The new clipboard genuinely starts with " /p..." — different + // clipboard content even though the editor text would look equal. + editor.handleInput(`\x1b[200~ ${paste}\x1b[201~`); + const text = editor.getText(); + assert.ok(text.includes("[paste #1")); + assert.ok(text.includes("[paste #2")); + }); + it("treats paste marker as single unit for right arrow", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("A"); From 206149ea7474f60bb2f6f9356f620723740184d9 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 14:05:24 +0000 Subject: [PATCH 06/10] fix(tui): swallow the trailing paste payload after paste-key expansion Co-authored-by: liruifengv --- .../tui/components/editor/custom-editor.ts | 33 ++++++++++++ .../components/editor/custom-editor.test.ts | 50 +++++++++++++++++++ 2 files changed, 83 insertions(+) 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 55957467e37..af75d7de14b 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -24,6 +24,11 @@ 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 BRACKET_PASTE_START = '\u001B[200~'; +const BRACKET_PASTE_END = '\u001B[201~'; +/** Window after a paste-key expansion during which exactly one trailing payload is swallowed. */ +const PASTE_PAYLOAD_SUPPRESS_MS = 1000; + // 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. @@ -156,6 +161,9 @@ export class CustomEditor extends Editor { */ public onPasteImage?: () => Promise; + private consumingPaste = false; + private consumeBuffer = ''; + private suppressPastePayloadUntil = 0; /** Serialize paste callbacks so Enter/typing cannot overtake an image paste. */ private pasteInFlight = false; private readonly pasteInputQueue: string[] = []; @@ -350,6 +358,28 @@ export class CustomEditor extends Editor { this.onNonEscapeInput?.(); } + // 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 (it would otherwise re-paste what the + // expansion just restored), while standalone pastes keep flowing to + // pi-tui's identical-content check. + if (this.consumingPaste) { + this.consumeBuffer += normalized; + if (this.consumeBuffer.includes(BRACKET_PASTE_END)) { + this.consumingPaste = false; + this.consumeBuffer = ''; + } + return; + } + if (normalized.includes(BRACKET_PASTE_START) && Date.now() < this.suppressPastePayloadUntil) { + this.suppressPastePayloadUntil = 0; + if (!normalized.includes(BRACKET_PASTE_END)) { + this.consumingPaste = true; + this.consumeBuffer = normalized; + } + 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 @@ -359,6 +389,9 @@ export class CustomEditor extends Editor { const pasteKey = process.platform === 'win32' ? 'alt+v' : Key.ctrl('v'); if (matchesKey(normalized, pasteKey)) { if (this.expandPasteMarkerAtCursor()) { + // Terminals that also forward the clipboard as bracketed paste will + // deliver that payload next — swallow exactly one. + this.suppressPastePayloadUntil = Date.now() + PASTE_PAYLOAD_SUPPRESS_MS; return; } if (this.onPasteImage !== undefined) { 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 fc4d2030ad1..a6c847bc988 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 @@ -601,6 +601,56 @@ describe('CustomEditor paste marker expansion', () => { 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('falls back to the text paste path when the image paste handler rejects', async () => { const editor = makeEditor(); const onTextPaste = vi.fn(); From 8b173545f7710b5be63a9aec118e75e267d9f123 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 14:25:16 +0000 Subject: [PATCH 07/10] fix(tui): tie trailing-payload suppression to the expanded content Co-authored-by: liruifengv --- .../tui/components/editor/custom-editor.ts | 43 ++++++++++++++++--- .../components/editor/custom-editor.test.ts | 14 ++++++ packages/pi-tui/AGENTS.md | 2 +- packages/pi-tui/src/components/editor.ts | 15 ++++--- packages/pi-tui/test/editor.test.ts | 6 +-- 5 files changed, 63 insertions(+), 17 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 af75d7de14b..bf920626856 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -164,6 +164,8 @@ 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[] = []; @@ -232,6 +234,23 @@ export class CustomEditor extends Editor { this.onInputModeChange?.(mode); } + /** + * 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.lastIndexOf(BRACKET_PASTE_END); + const content = end > start ? payload.slice(start, end) : ''; + if (content === expected) return; + super.handleInput.call(this, payload); + } + private hasAutocompleteActivity(): boolean { const autocomplete = this as unknown as AutocompleteInternals; return ( @@ -360,23 +379,32 @@ export class CustomEditor extends Editor { // 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 (it would otherwise re-paste what the - // expansion just restored), while standalone pastes keep flowing to - // pi-tui's identical-content check. + // 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 (normalized.includes(BRACKET_PASTE_START) && Date.now() < this.suppressPastePayloadUntil) { + if ( + normalized.includes(BRACKET_PASTE_START) && + this.pendingExpandedContent !== undefined && + Date.now() < this.suppressPastePayloadUntil + ) { this.suppressPastePayloadUntil = 0; if (!normalized.includes(BRACKET_PASTE_END)) { this.consumingPaste = true; this.consumeBuffer = normalized; + return; } + this.handleTrailingPayload(normalized); return; } @@ -388,9 +416,12 @@ 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()) { + const expanded = this.expandPasteMarkerAtCursor(); + if (expanded !== undefined) { // Terminals that also forward the clipboard as bracketed paste will - // deliver that payload next — swallow exactly one. + // 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; } 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 a6c847bc988..eb82386f41a 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 @@ -651,6 +651,20 @@ describe('CustomEditor paste marker expansion', () => { } }); + 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('falls back to the text paste path when the image paste handler rejects', async () => { const editor = makeEditor(); const onTextPaste = vi.fn(); diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 1c0ee8110bf..c5dce897173 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,7 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. -9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content while preserving the paste registry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check compares against the genuine clipboard text — whether a leading space was actually synthesized by the path-spacing rule is recorded per paste id in `pasteSyntheticSpacing` and maintained across marker renumbering, registry clears, and undo snapshots — never inferred from the two values). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns false when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", "does not expand when the re-paste differs by a genuine leading space", "does not expand when a leading space is genuine on the re-paste of path-starting content", "expands the marker when the re-paste receives the synthetic leading space", and "does not expand when the re-paste genuinely carries the space the stored paste synthesized" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content and returns that content (`undefined` when the cursor is not on a live marker), preserving the paste registry so undo restores both the marker text and its entry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check compares against the genuine clipboard text — whether a leading space was actually synthesized by the path-spacing rule is recorded per paste id in `pasteSyntheticSpacing` and maintained across marker renumbering, registry clears, and undo snapshots — never inferred from the two values). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns undefined when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", "does not expand when the re-paste differs by a genuine leading space", "does not expand when a leading space is genuine on the re-paste of path-starting content", "expands the marker when the re-paste receives the synthetic leading space", and "does not expand when the re-paste genuinely carries the space the stored paste synthesized" in `test/editor.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 79f38df831e..257361086f8 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1137,10 +1137,11 @@ export class Editor implements Component, Focusable { /** * Expand the paste marker under the cursor, replacing it with its stored - * content. The paste registry is preserved so undo restores both the marker - * text and its entry. Returns false when the cursor is not on a live marker. + * content and returning that content. The paste registry is preserved so + * undo restores both the marker text and its entry. Returns undefined when + * the cursor is not on a live marker. */ - expandPasteMarkerAtCursor(): boolean { + expandPasteMarkerAtCursor(): string | undefined { const currentLine = this.state.lines[this.state.cursorLine] || ""; PASTE_MARKER_REGEX.lastIndex = 0; for (const match of currentLine.matchAll(PASTE_MARKER_REGEX)) { @@ -1150,7 +1151,7 @@ export class Editor implements Component, Focusable { const pasteId = Number(match[1]); const content = this.pastes.get(pasteId); - if (content === undefined) return false; + if (content === undefined) return undefined; const text = this.getText(); const offset = @@ -1158,9 +1159,9 @@ export class Editor implements Component, Focusable { start; const newText = text.slice(0, offset) + content + text.slice(offset + match[0].length); this.setText(newText, { preservePasteRegistry: true }); - return true; + return content; } - return false; + return undefined; } /** @@ -1175,7 +1176,7 @@ export class Editor implements Component, Focusable { const end = start + match[0].length; if (this.state.cursorCol < start || this.state.cursorCol > end) continue; if (!this.isSamePasteContent(Number(match[1]), filteredText, hadSyntheticSpace)) return false; - return this.expandPasteMarkerAtCursor(); + return this.expandPasteMarkerAtCursor() !== undefined; } return false; } diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 34291d72adf..86208bd5f27 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4102,14 +4102,14 @@ describe("Editor component", () => { editor.handleInput(`\x1b[200~${paste}\x1b[201~`); assert.match(editor.getText(), /\[paste #1/); - assert.strictEqual(editor.expandPasteMarkerAtCursor(), true); + assert.strictEqual(editor.expandPasteMarkerAtCursor(), paste); assert.strictEqual(editor.getText(), paste); }); - it("expandPasteMarkerAtCursor returns false when the cursor is not on a marker", () => { + it("expandPasteMarkerAtCursor returns undefined when the cursor is not on a marker", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.setText("plain"); - assert.strictEqual(editor.expandPasteMarkerAtCursor(), false); + assert.strictEqual(editor.expandPasteMarkerAtCursor(), undefined); assert.strictEqual(editor.getText(), "plain"); }); From 18c235d69f09f236872cc7ca0f7980c2af05a326 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 14:43:27 +0000 Subject: [PATCH 08/10] fix(tui): compare trailing payloads on canonical clipboard form Co-authored-by: liruifengv --- .../tui/components/editor/custom-editor.ts | 2 +- .../components/editor/custom-editor.test.ts | 32 +++++++++++ packages/pi-tui/AGENTS.md | 2 +- packages/pi-tui/src/components/editor.ts | 54 ++++++++++--------- 4 files changed, 64 insertions(+), 26 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 bf920626856..7ecd7d37d7c 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -247,7 +247,7 @@ export class CustomEditor extends Editor { const start = payload.indexOf(BRACKET_PASTE_START) + BRACKET_PASTE_START.length; const end = payload.lastIndexOf(BRACKET_PASTE_END); const content = end > start ? payload.slice(start, end) : ''; - if (content === expected) return; + if (this.canonicalizePastedText(content) === expected) return; super.handleInput.call(this, payload); } 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 eb82386f41a..b7e7b1433fe 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 @@ -665,6 +665,38 @@ describe('CustomEditor paste marker expansion', () => { 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('falls back to the text paste path when the image paste handler rejects', async () => { const editor = makeEditor(); const onTextPaste = vi.fn(); diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index c5dce897173..1fdd682b242 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,7 +14,7 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. -9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content and returns that content (`undefined` when the cursor is not on a live marker), preserving the paste registry so undo restores both the marker text and its entry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check compares against the genuine clipboard text — whether a leading space was actually synthesized by the path-spacing rule is recorded per paste id in `pasteSyntheticSpacing` and maintained across marker renumbering, registry clears, and undo snapshots — never inferred from the two values). Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns undefined when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", "does not expand when the re-paste differs by a genuine leading space", "does not expand when a leading space is genuine on the re-paste of path-starting content", "expands the marker when the re-paste receives the synthetic leading space", and "does not expand when the re-paste genuinely carries the space the stored paste synthesized" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — paste-marker expansion gestures**: the public `expandPasteMarkerAtCursor()` replaces the marker under the cursor with its stored content and returns the canonical clipboard text that produced it (any synthetic leading space the path-spacing rule added is stripped — that is the value a trailing paste payload should be compared against; `undefined` when the cursor is not on a live marker), preserving the paste registry so undo restores both the marker text and its entry (the app's CustomEditor relies on it for the explicit paste-key gesture), and `handlePaste` runs `expandMarkerForIdenticalPaste` before pushing the undo snapshot so that re-pasting content identical to what the marker under the cursor holds expands that marker instead of inserting a duplicate (second-paste gesture; the identity check compares against the genuine clipboard text — whether a leading space was actually synthesized by the path-spacing rule is recorded per paste id in `pasteSyntheticSpacing` and maintained across marker renumbering, registry clears, and undo snapshots — never inferred from the two values). The raw-payload normalization itself (CSI-u decode, line endings, tabs, non-printables) is shared through the public `canonicalizePastedText()` so the app layer compares payloads on the same canonical form. Guarding tests: the "Paste marker atomic behavior" group cases "expands the marker when the identical content is pasted onto it again", "pastes different content normally even when the cursor is on a marker", "creates a second marker for the identical content when the cursor is not on the first marker", "expands the marker when the identical paste arrives split across chunks", "expandPasteMarkerAtCursor replaces the marker with its stored content", "expandPasteMarkerAtCursor returns undefined when the cursor is not on a marker", "undo after a second-paste expansion restores the marker and its registry entry", "expands the marker on re-paste when the stored content carries a synthetic leading space", "does not expand when the re-paste differs by a genuine leading space", "does not expand when a leading space is genuine on the re-paste of path-starting content", "expands the marker when the re-paste receives the synthetic leading space", and "does not expand when the re-paste genuinely carries the space the stored paste synthesized" in `test/editor.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 257361086f8..8f4c0a22114 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1137,9 +1137,11 @@ export class Editor implements Component, Focusable { /** * Expand the paste marker under the cursor, replacing it with its stored - * content and returning that content. The paste registry is preserved so - * undo restores both the marker text and its entry. Returns undefined when - * the cursor is not on a live marker. + * content, and return the canonical clipboard text that produced it (any + * synthetic leading space the path-spacing rule added is stripped — that is + * the value a trailing paste payload should be compared against). The paste + * registry is preserved so undo restores both the marker text and its + * entry. Returns undefined when the cursor is not on a live marker. */ expandPasteMarkerAtCursor(): string | undefined { const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1159,7 +1161,7 @@ export class Editor implements Component, Focusable { start; const newText = text.slice(0, offset) + content + text.slice(offset + match[0].length); this.setText(newText, { preservePasteRegistry: true }); - return content; + return this.pasteSyntheticSpacing.has(pasteId) ? content.slice(1) : content; } return undefined; } @@ -1244,6 +1246,27 @@ export class Editor implements Component, Focusable { return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " "); } + /** + * Normalize raw bracketed-paste text the way handlePaste stores it: + * CSI-u Ctrl+ sequences decoded (tmux popups re-encode control + * bytes that way), line endings normalized, tabs expanded, non-printable + * characters filtered. The context-dependent path-spacing adjustment is + * deliberately not part of this. + */ + canonicalizePastedText(pastedText: string): string { + const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => { + const cp = Number(code); + if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96); + if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64); + return match; + }); + const cleanText = this.normalizeText(decodedText); + return cleanText + .split("") + .filter((char) => char === "\n" || char.charCodeAt(0) >= 32) + .join(""); + } + /** * Internal text insertion at cursor. Handles single and multi-line text. * Does not push undo snapshots or trigger autocomplete - caller is responsible. @@ -1367,26 +1390,9 @@ export class Editor implements Component, Focusable { this.exitHistoryBrowsing(); this.lastAction = null; - // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode - // control bytes inside bracketed paste as CSI-u Ctrl+ sequences - // (ESC [ ; 5 u). Decode those back to their literal byte so the - // per-char filter below preserves newlines instead of stripping ESC and - // leaking the printable tail (e.g. "[106;5u") into the editor. - const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => { - const cp = Number(code); - if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96); - if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64); - return match; - }); - - // Clean the pasted text: normalize line endings, expand tabs - const cleanText = this.normalizeText(decodedText); - - // Filter out non-printable characters except newlines - let filteredText = cleanText - .split("") - .filter((char) => char === "\n" || char.charCodeAt(0) >= 32) - .join(""); + // Normalize the raw payload (CSI-u decode, line endings, tabs, + // non-printables) the same way stored pastes are stored. + let filteredText = this.canonicalizePastedText(pastedText); // If pasting a file path (starts with /, ~, or .) and the character before // the cursor is a word character, prepend a space for better readability From 0ef5424947673b89769838a88d5b4cf99cbf1881 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 15:05:22 +0000 Subject: [PATCH 09/10] fix(tui): refresh pending suppression per gesture and forward post-payload bytes Co-authored-by: liruifengv --- .../tui/components/editor/custom-editor.ts | 19 +++++++++--- .../components/editor/custom-editor.test.ts | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 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 7ecd7d37d7c..b6d35a3e568 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -245,10 +245,18 @@ export class CustomEditor extends Editor { this.pendingExpandedContent = undefined; if (expected === undefined) return; const start = payload.indexOf(BRACKET_PASTE_START) + BRACKET_PASTE_START.length; - const end = payload.lastIndexOf(BRACKET_PASTE_END); - const content = end > start ? payload.slice(start, end) : ''; - if (this.canonicalizePastedText(content) === expected) return; - super.handleInput.call(this, payload); + 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) { + super.handleInput.call(this, payload); + return; + } + // The payload itself is swallowed, but bytes the terminal batched after it + // are real input and must not be dropped with it. + if (suffix.length > 0) { + super.handleInput.call(this, suffix); + } } private hasAutocompleteActivity(): boolean { @@ -416,6 +424,9 @@ 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)) { + // 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) { // Terminals that also forward the clipboard as bracketed paste will 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 b7e7b1433fe..0c2fc5e105c 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 @@ -697,6 +697,36 @@ describe('CustomEditor paste marker expansion', () => { 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('falls back to the text paste path when the image paste handler rejects', async () => { const editor = makeEditor(); const onTextPaste = vi.fn(); From c53b9a66111446e954b3aaa0b1377ffec5c348a0 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Mon, 31 Aug 2026 15:24:55 +0000 Subject: [PATCH 10/10] fix(tui): disarm payload suppression on intervening input and centralize paste constants Co-authored-by: liruifengv --- .../src/tui/components/editor/custom-editor.ts | 18 ++++++++++-------- apps/kimi-code/src/tui/constant/paste.ts | 4 ++++ .../components/editor/custom-editor.test.ts | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 apps/kimi-code/src/tui/constant/paste.ts 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 b6d35a3e568..0b2c52b6d00 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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'; @@ -24,11 +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 BRACKET_PASTE_START = '\u001B[200~'; -const BRACKET_PASTE_END = '\u001B[201~'; -/** Window after a paste-key expansion during which exactly one trailing payload is swallowed. */ -const PASTE_PAYLOAD_SUPPRESS_MS = 1000; - // 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. @@ -249,13 +245,14 @@ export class CustomEditor extends Editor { 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) { - super.handleInput.call(this, payload); + this.handleInput(payload); return; } // The payload itself is swallowed, but bytes the terminal batched after it - // are real input and must not be dropped with 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) { - super.handleInput.call(this, suffix); + this.handleInput(suffix); } } @@ -416,6 +413,11 @@ export class CustomEditor extends Editor { 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 diff --git a/apps/kimi-code/src/tui/constant/paste.ts b/apps/kimi-code/src/tui/constant/paste.ts new file mode 100644 index 00000000000..cee0fde6765 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/paste.ts @@ -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; 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 0c2fc5e105c..4b7164391ec 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 @@ -727,6 +727,21 @@ describe('CustomEditor paste marker expansion', () => { 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();