fix(tui): deduplicate kitty printable input

closes #3780
This commit is contained in:
Mario Zechner
2026-04-27 21:02:40 +02:00
parent 27c0c2ec13
commit bdb416cbc0
3 changed files with 52 additions and 7 deletions

View File

@@ -2,6 +2,10 @@
## [Unreleased] ## [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.2] - 2026-04-24
## [0.70.1] - 2026-04-24 ## [0.70.1] - 2026-04-24

View File

@@ -181,6 +181,14 @@ function isCompleteApcSequence(data: string): "complete" | "incomplete" {
/** /**
* Split accumulated buffer into complete sequences * 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 } { function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
const sequences: string[] = []; const sequences: string[] = [];
let pos = 0; let pos = 0;
@@ -246,6 +254,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
private readonly timeoutMs: number; private readonly timeoutMs: number;
private pasteMode: boolean = false; private pasteMode: boolean = false;
private pasteBuffer: string = ""; private pasteBuffer: string = "";
private pendingKittyPrintableCodepoint: number | undefined;
constructor(options: StdinBufferOptions = {}) { constructor(options: StdinBufferOptions = {}) {
super(); super();
@@ -274,7 +283,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
} }
if (str.length === 0 && this.buffer.length === 0) { if (str.length === 0 && this.buffer.length === 0) {
this.emit("data", ""); this.emitDataSequence("");
return; return;
} }
@@ -291,6 +300,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
this.pasteMode = false; this.pasteMode = false;
this.pasteBuffer = ""; this.pasteBuffer = "";
this.pendingKittyPrintableCodepoint = undefined;
this.emit("paste", pastedContent); this.emit("paste", pastedContent);
@@ -307,10 +317,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
const beforePaste = this.buffer.slice(0, startIndex); const beforePaste = this.buffer.slice(0, startIndex);
const result = extractCompleteSequences(beforePaste); const result = extractCompleteSequences(beforePaste);
for (const sequence of result.sequences) { 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.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length);
this.pasteMode = true; this.pasteMode = true;
this.pasteBuffer = this.buffer; this.pasteBuffer = this.buffer;
@@ -323,6 +334,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
this.pasteMode = false; this.pasteMode = false;
this.pasteBuffer = ""; this.pasteBuffer = "";
this.pendingKittyPrintableCodepoint = undefined;
this.emit("paste", pastedContent); this.emit("paste", pastedContent);
@@ -337,7 +349,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
this.buffer = result.remainder; this.buffer = result.remainder;
for (const sequence of result.sequences) { for (const sequence of result.sequences) {
this.emit("data", sequence); this.emitDataSequence(sequence);
} }
if (this.buffer.length > 0) { if (this.buffer.length > 0) {
@@ -345,12 +357,23 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
const flushed = this.flush(); const flushed = this.flush();
for (const sequence of flushed) { for (const sequence of flushed) {
this.emit("data", sequence); this.emitDataSequence(sequence);
} }
}, this.timeoutMs); }, 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[] { flush(): string[] {
if (this.timeout) { if (this.timeout) {
clearTimeout(this.timeout); clearTimeout(this.timeout);
@@ -363,6 +386,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
const sequences = [this.buffer]; const sequences = [this.buffer];
this.buffer = ""; this.buffer = "";
this.pendingKittyPrintableCodepoint = undefined;
return sequences; return sequences;
} }
@@ -374,6 +398,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
this.buffer = ""; this.buffer = "";
this.pasteMode = false; this.pasteMode = false;
this.pasteBuffer = ""; this.pasteBuffer = "";
this.pendingKittyPrintableCodepoint = undefined;
} }
getBuffer(): string { getBuffer(): string {

View File

@@ -204,9 +204,25 @@ describe("StdinBuffer", () => {
assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]); assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]);
}); });
it("should handle Kitty sequence followed by plain characters", () => { it("should drop raw duplicate character after matching Kitty printable sequence", () => {
processInput("\x1b[97ua"); processInput("\x1b[224uà");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "a"]); 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", () => { it("should handle rapid typing simulation with Kitty protocol", () => {