From bdb416cbc04af02abe620e65f75a46817fc64c95 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 27 Apr 2026 21:02:40 +0200 Subject: [PATCH] fix(tui): deduplicate kitty printable input closes #3780 --- packages/tui/CHANGELOG.md | 4 ++++ packages/tui/src/stdin-buffer.ts | 33 ++++++++++++++++++++++---- packages/tui/test/stdin-buffer.test.ts | 22 ++++++++++++++--- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8cc3b91e..8bb8ed03 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed duplicate printable characters from Kitty keyboard protocol CSI-u plus raw character input on layouts such as Italian ([#3780](https://github.com/badlogic/pi-mono/issues/3780)) + ## [0.70.2] - 2026-04-24 ## [0.70.1] - 2026-04-24 diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index 5b2f977b..4aaf4627 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -181,6 +181,14 @@ function isCompleteApcSequence(data: string): "complete" | "incomplete" { /** * Split accumulated buffer into complete sequences */ +function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined { + const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/); + if (!match) return undefined; + + const codepoint = parseInt(match[1]!, 10); + return codepoint >= 32 ? codepoint : undefined; +} + function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } { const sequences: string[] = []; let pos = 0; @@ -246,6 +254,7 @@ export class StdinBuffer extends EventEmitter { private readonly timeoutMs: number; private pasteMode: boolean = false; private pasteBuffer: string = ""; + private pendingKittyPrintableCodepoint: number | undefined; constructor(options: StdinBufferOptions = {}) { super(); @@ -274,7 +283,7 @@ export class StdinBuffer extends EventEmitter { } if (str.length === 0 && this.buffer.length === 0) { - this.emit("data", ""); + this.emitDataSequence(""); return; } @@ -291,6 +300,7 @@ export class StdinBuffer extends EventEmitter { this.pasteMode = false; this.pasteBuffer = ""; + this.pendingKittyPrintableCodepoint = undefined; this.emit("paste", pastedContent); @@ -307,10 +317,11 @@ export class StdinBuffer extends EventEmitter { const beforePaste = this.buffer.slice(0, startIndex); const result = extractCompleteSequences(beforePaste); for (const sequence of result.sequences) { - this.emit("data", sequence); + this.emitDataSequence(sequence); } } + this.pendingKittyPrintableCodepoint = undefined; this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length); this.pasteMode = true; this.pasteBuffer = this.buffer; @@ -323,6 +334,7 @@ export class StdinBuffer extends EventEmitter { this.pasteMode = false; this.pasteBuffer = ""; + this.pendingKittyPrintableCodepoint = undefined; this.emit("paste", pastedContent); @@ -337,7 +349,7 @@ export class StdinBuffer extends EventEmitter { this.buffer = result.remainder; for (const sequence of result.sequences) { - this.emit("data", sequence); + this.emitDataSequence(sequence); } if (this.buffer.length > 0) { @@ -345,12 +357,23 @@ export class StdinBuffer extends EventEmitter { const flushed = this.flush(); for (const sequence of flushed) { - this.emit("data", sequence); + this.emitDataSequence(sequence); } }, this.timeoutMs); } } + private emitDataSequence(sequence: string): void { + const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined; + if (rawCodepoint !== undefined && rawCodepoint === this.pendingKittyPrintableCodepoint) { + this.pendingKittyPrintableCodepoint = undefined; + return; + } + + this.pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence); + this.emit("data", sequence); + } + flush(): string[] { if (this.timeout) { clearTimeout(this.timeout); @@ -363,6 +386,7 @@ export class StdinBuffer extends EventEmitter { const sequences = [this.buffer]; this.buffer = ""; + this.pendingKittyPrintableCodepoint = undefined; return sequences; } @@ -374,6 +398,7 @@ export class StdinBuffer extends EventEmitter { this.buffer = ""; this.pasteMode = false; this.pasteBuffer = ""; + this.pendingKittyPrintableCodepoint = undefined; } getBuffer(): string { diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 5fb0d6ff..549b15f2 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -204,9 +204,25 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]); }); - it("should handle Kitty sequence followed by plain characters", () => { - processInput("\x1b[97ua"); - assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "a"]); + it("should drop raw duplicate character after matching Kitty printable sequence", () => { + processInput("\x1b[224uà"); + assert.deepStrictEqual(emittedSequences, ["\x1b[224u"]); + }); + + it("should drop raw duplicate character after matching Kitty printable sequence across chunks", () => { + processInput("\x1b[64u"); + processInput("@"); + assert.deepStrictEqual(emittedSequences, ["\x1b[64u"]); + }); + + it("should keep non-matching plain character after Kitty printable sequence", () => { + processInput("\x1b[97ub"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "b"]); + }); + + it("should keep raw character after modified Kitty printable sequence", () => { + processInput("\x1b[64;3u@"); + assert.deepStrictEqual(emittedSequences, ["\x1b[64;3u", "@"]); }); it("should handle rapid typing simulation with Kitty protocol", () => {