Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strict-teams-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

fix(pi-tui): skip Kitty keyboard protocol on Windows Terminal to prevent Cyrillic doubling
33 changes: 32 additions & 1 deletion packages/pi-tui/src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle WSL Windows Terminal sessions

This gate makes the workaround native-Windows-only, but Windows Terminal tabs running WSL expose the same terminal emulator while Node reports process.platform === "linux". In that environment WT_SESSION is present but this helper returns false, so the app still negotiates Kitty and the Cyrillic doubling remains for WSL users; the SSH checks below already distinguish remote sessions. Consider keying off WT_SESSION without the win32 requirement or adding explicit WSL handling.

Useful? React with 👍 / 👎.

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;
Expand Down Expand Up @@ -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);
}

Expand Down
26 changes: 25 additions & 1 deletion packages/pi-tui/test/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,20 @@ 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;
let dataHandler: ((data: string) => void) | undefined;
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));
Expand Down Expand Up @@ -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);
}
},
Expand All @@ -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 {
Expand Down