diff --git a/.changeset/strict-teams-attend.md b/.changeset/strict-teams-attend.md new file mode 100644 index 0000000000..e63188f981 --- /dev/null +++ b/.changeset/strict-teams-attend.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +fix(pi-tui): skip Kitty keyboard protocol on Windows Terminal to prevent Cyrillic doubling diff --git a/packages/pi-tui/src/terminal.ts b/packages/pi-tui/src/terminal.ts index 3caba9789b..c179bd327b 100644 --- a/packages/pi-tui/src/terminal.ts +++ b/packages/pi-tui/src/terminal.ts @@ -41,6 +41,21 @@ export function isAppleTerminalSession(): boolean { return process.platform === "darwin" && process.env['TERM_PROGRAM'] === "Apple_Terminal"; } +/** + * Detect Windows Terminal by checking for the WT_SESSION environment variable. + * Windows Terminal sets WT_SESSION to a unique GUID for every tab. + * Exclude SSH sessions (where the terminal is remote, not local WT). + */ +function isWindowsTerminal(): boolean { + return ( + process.platform === "win32" && + Boolean(process.env['WT_SESSION']) && + !process.env['SSH_CONNECTION'] && + !process.env['SSH_CLIENT'] && + !process.env['SSH_TTY'] + ); +} + export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { if (isAppleTerminal && data === "\r" && isShiftPressed) return APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE; return data; @@ -216,12 +231,28 @@ export class ProcessTerminal implements Terminal { * - 1 = disambiguate escape codes * - 2 = report event types (press/repeat/release) * - 4 = report alternate keys (shifted key, base layout key) + * + * On Windows Terminal, Kitty keyboard protocol with flag 4 (alternate keys) + * causes Cyrillic and other non-Latin characters to be double-processed: + * the terminal sends both the CSI-u sequence AND the raw character, resulting + * in doubled input. Skip Kitty protocol on Windows Terminal and fall back to + * modifyOtherKeys instead. */ private queryAndEnableKittyProtocol(): void { this.setupStdinBuffer(); process.stdin.on("data", this.stdinDataHandler!); - this.keyboardProtocolPushed = true; this.clearKeyboardProtocolNegotiationBuffer(); + + // Windows Terminal: skip Kitty keyboard protocol to avoid Cyrillic doubling. + // The terminal sends CSI-u sequences for non-Latin characters when Kitty + // protocol flag 4 (alternate keys) is active, but also sends the raw + // character, causing every Cyrillic keystroke to be inserted twice. + if (isWindowsTerminal()) { + this.enableModifyOtherKeys(); + return; + } + + this.keyboardProtocolPushed = true; process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); } diff --git a/packages/pi-tui/test/terminal.test.ts b/packages/pi-tui/test/terminal.test.ts index a356bff1d5..7f80fb59f0 100644 --- a/packages/pi-tui/test/terminal.test.ts +++ b/packages/pi-tui/test/terminal.test.ts @@ -31,7 +31,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { cleanup(): void; }; - function setupNegotiation(): NegotiationHarness { + function setupNegotiation(options?: { windowsTerminal?: boolean }): NegotiationHarness { const terminal = new ProcessTerminal(); const writes: string[] = []; let input: string | undefined; @@ -39,6 +39,12 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { let cleaned = false; const previousWrite = process.stdout.write; const previousOn = process.stdin.on; + const previousWtSession = process.env['WT_SESSION']; + if (options?.windowsTerminal) { + process.env['WT_SESSION'] = 'test-guid-1234'; + } else { + delete process.env['WT_SESSION']; + } process.stdout.write = ((chunk: string | Uint8Array) => { writes.push(String(chunk)); @@ -76,6 +82,11 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { } finally { process.stdout.write = previousWrite; process.stdin.on = previousOn; + if (previousWtSession === undefined) { + delete process.env['WT_SESSION']; + } else { + process.env['WT_SESSION'] = previousWtSession; + } setKittyProtocolActive(false); } }, @@ -93,6 +104,19 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { } }); + it("skips Kitty protocol and enables modifyOtherKeys on Windows Terminal (WT_SESSION)", () => { + const harness = setupNegotiation({ windowsTerminal: true }); + try { + // Should NOT send Kitty query; should enable modifyOtherKeys instead + assert.equal(harness.writes[0], "\x1b[>4;2m"); + assert.equal(harness.writes.includes("\x1b[>7u\x1b[?u\x1b[c"), false); + assert.equal(harness.terminal.kittyProtocolActive, false); + assert.equal(harness.terminal.modifyOtherKeysActive, true); + } finally { + harness.cleanup(); + } + }); + it("activates Kitty mode for non-zero negotiated flags", () => { const harness = setupNegotiation(); try {