From 6b55d6859d89e7c8d8ae723255be4c1e1866d68e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 20 Apr 2026 15:36:10 +0200 Subject: [PATCH] fix(tui): restore shifted xterm input closes #3436 --- packages/coding-agent/CHANGELOG.md | 1 + packages/tui/CHANGELOG.md | 4 +++ packages/tui/src/components/editor.ts | 13 +++---- packages/tui/src/keys.ts | 52 +++++++++++++++++++++++---- packages/tui/test/editor.test.ts | 16 +++++++++ packages/tui/test/keys.test.ts | 34 +++++++++++++++++- 6 files changed, 106 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8de772b7..dc36b36b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed missing `@sinclair/typebox` runtime dependency in `@mariozechner/pi-coding-agent`, so strict pnpm installs no longer fail with `ERR_MODULE_NOT_FOUND` when starting `pi` ([#3434](https://github.com/badlogic/pi-mono/issues/3434)) +- Fixed xterm uppercase typing in the interactive editor by decoding printable `modifyOtherKeys` input and normalizing shifted letter matching, so `Shift+letter` no longer disappears in `pi` ([#3436](https://github.com/badlogic/pi-mono/issues/3436)) - Fixed `/compact` to reuse the session thinking level for compaction summaries instead of forcing `high`, avoiding invalid reasoning-effort errors on `github-copilot/claude-opus-4.7` sessions configured for `medium` thinking ([#3438](https://github.com/badlogic/pi-mono/issues/3438)) - Fixed shared/exported plain-text tool output to preserve indentation instead of collapsing leading whitespace in the web share page ([#3440](https://github.com/badlogic/pi-mono/issues/3440)) - Fixed skill resolution to dedupe symlinked aliases by canonical path, so `pi config` no longer shows duplicate skill entries when `~/.pi/agent/skills` points to `~/.agents/skills` ([#3405](https://github.com/badlogic/pi-mono/issues/3405)) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c0a2e5f0..be1caa7b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed xterm `modifyOtherKeys` printable input so shifted uppercase letters insert correctly in the editor and shifted letter bindings parse and match consistently ([#3436](https://github.com/badlogic/pi-mono/issues/3436)) + ## [0.67.68] - 2026-04-17 ## [0.67.67] - 2026-04-17 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 6dfe983b..419de50a 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,6 +1,6 @@ import type { AutocompleteProvider, AutocompleteSuggestions, CombinedAutocompleteProvider } from "../autocomplete.js"; import { getKeybindings } from "../keybindings.js"; -import { decodeKittyPrintable, matchesKey } from "../keys.js"; +import { decodePrintableKey, matchesKey } from "../keys.js"; import { KillRing } from "../kill-ring.js"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js"; import { UndoStack } from "../undo-stack.js"; @@ -542,11 +542,12 @@ export class Editor implements Component, Focusable { return; } - if (data.charCodeAt(0) >= 32) { + const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined); + if (printable !== undefined) { // Printable character - perform the jump const direction = this.jumpMode; this.jumpMode = null; - this.jumpToChar(data, direction); + this.jumpToChar(printable, direction); return; } @@ -807,9 +808,9 @@ export class Editor implements Component, Focusable { return; } - const kittyPrintable = decodeKittyPrintable(data); - if (kittyPrintable !== undefined) { - this.insertCharacter(kittyPrintable); + const printable = decodePrintableKey(data); + if (printable !== undefined) { + this.insertCharacter(printable); return; } diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 896e5d88..0d3c7085 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -357,6 +357,14 @@ function normalizeKittyFunctionalCodepoint(codepoint: number): number { return KITTY_FUNCTIONAL_KEY_EQUIVALENTS.get(codepoint) ?? codepoint; } +function normalizeShiftedLetterIdentityCodepoint(codepoint: number, modifier: number): number { + const effectiveModifier = modifier & ~LOCK_MASK; + if ((effectiveModifier & MODIFIERS.shift) !== 0 && codepoint >= 65 && codepoint <= 90) { + return codepoint + 32; + } + return codepoint; +} + const LEGACY_KEY_SEQUENCES = { up: ["\x1b[A", "\x1bOA"], down: ["\x1b[B", "\x1bOB"], @@ -651,8 +659,14 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM // Check if modifiers match if (actualMod !== expectedMod) return false; - const normalizedCodepoint = normalizeKittyFunctionalCodepoint(parsed.codepoint); - const normalizedExpectedCodepoint = normalizeKittyFunctionalCodepoint(expectedCodepoint); + const normalizedCodepoint = normalizeShiftedLetterIdentityCodepoint( + normalizeKittyFunctionalCodepoint(parsed.codepoint), + parsed.modifier, + ); + const normalizedExpectedCodepoint = normalizeShiftedLetterIdentityCodepoint( + normalizeKittyFunctionalCodepoint(expectedCodepoint), + expectedModifier, + ); // Primary match: codepoint matches directly after normalizing functional keys if (normalizedCodepoint === normalizedExpectedCodepoint) return true; @@ -751,7 +765,12 @@ function isDigitKey(key: string): boolean { function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { if (expectedModifier === 0) return false; - return matchesModifyOtherKeys(data, expectedKeycode, expectedModifier); + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed || parsed.modifier !== expectedModifier) return false; + return ( + normalizeShiftedLetterIdentityCodepoint(parsed.codepoint, parsed.modifier) === + normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier) + ); } function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined { @@ -1192,17 +1211,18 @@ export function matchesKey(data: string, keyId: KeyId): boolean { */ function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: number): string | undefined { const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint); + const identityCodepoint = normalizeShiftedLetterIdentityCodepoint(normalizedCodepoint, modifier); // Use base layout key only when codepoint is not a recognized Latin // letter (a-z), digit (0-9), or symbol (/, -, [, ;, etc.). For those, // the codepoint is authoritative regardless of physical key position. // This prevents remapped layouts (Dvorak, Colemak, xremap, etc.) from // reporting the wrong key name based on the QWERTY physical position. - const isLatinLetter = normalizedCodepoint >= 97 && normalizedCodepoint <= 122; // a-z - const isDigit = normalizedCodepoint >= 48 && normalizedCodepoint <= 57; // 0-9 - const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(normalizedCodepoint)); + const isLatinLetter = identityCodepoint >= 97 && identityCodepoint <= 122; // a-z + const isDigit = identityCodepoint >= 48 && identityCodepoint <= 57; // 0-9 + const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(identityCodepoint)); const effectiveCodepoint = - isLatinLetter || isDigit || isKnownSymbol ? normalizedCodepoint : (baseLayoutKey ?? normalizedCodepoint); + isLatinLetter || isDigit || isKnownSymbol ? identityCodepoint : (baseLayoutKey ?? identityCodepoint); let keyName: string | undefined; if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape"; @@ -1360,3 +1380,21 @@ export function decodeKittyPrintable(data: string): string | undefined { return undefined; } } + +function decodeModifyOtherKeysPrintable(data: string): string | undefined { + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed) return undefined; + const modifier = parsed.modifier & ~LOCK_MASK; + if ((modifier & ~MODIFIERS.shift) !== 0) return undefined; + if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined; + + try { + return String.fromCodePoint(parsed.codepoint); + } catch { + return undefined; + } +} + +export function decodePrintableKey(data: string): string | undefined { + return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data); +} diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index ebd03b85..51566eb9 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -374,6 +374,22 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), ""); }); + + it("inserts shifted CSI-u letters as text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\x1b[69;2u"); + + assert.strictEqual(editor.getText(), "E"); + }); + + it("inserts shifted xterm modifyOtherKeys letters as text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\x1b[27;2;69~"); + + assert.strictEqual(editor.getText(), "E"); + }); }); describe("Unicode text editing behavior", () => { diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index e1c204fd..392214cd 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -4,7 +4,14 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { decodeKittyPrintable, Key, matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js"; +import { + decodeKittyPrintable, + decodePrintableKey, + Key, + matchesKey, + parseKey, + setKittyProtocolActive, +} from "../src/keys.js"; function withEnv(name: string, value: string | undefined, fn: () => void): void { const previous = process.env[name]; @@ -268,6 +275,14 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x1b[27;2;49~"), "shift+1"); }); + it("should match xterm modifyOtherKeys shifted uppercase letters", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;2;69~", "shift+e"), true); + assert.strictEqual(matchesKey("\x1b[27;6;69~", "ctrl+shift+e"), true); + assert.strictEqual(parseKey("\x1b[27;2;69~"), "shift+e"); + assert.strictEqual(parseKey("\x1b[27;6;69~"), "shift+ctrl+e"); + }); + it("should match Ctrl+Alt+letter via CSI-u when kitty inactive", () => { setKittyProtocolActive(false); assert.strictEqual(matchesKey("\x1b[104;7u", "ctrl+alt+h"), true); @@ -493,6 +508,16 @@ describe("decodeKittyPrintable", () => { }); }); +describe("decodePrintableKey", () => { + it("should decode printable xterm modifyOtherKeys sequences", () => { + assert.strictEqual(decodePrintableKey("\x1b[27;2;69~"), "E"); + assert.strictEqual(decodePrintableKey("\x1b[27;2;196~"), "Ä"); + assert.strictEqual(decodePrintableKey("\x1b[27;2;32~"), " "); + assert.strictEqual(decodePrintableKey("\x1b[27;2;13~"), undefined); + assert.strictEqual(decodePrintableKey("\x1b[27;6;69~"), undefined); + }); +}); + describe("parseKey", () => { describe("Kitty protocol with alternate keys", () => { it("should return Latin key name when base layout key is present", () => { @@ -526,6 +551,13 @@ describe("parseKey", () => { setKittyProtocolActive(false); }); + it("should parse shifted uppercase CSI-u letters as shift+letter", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b[69;2u", "shift+e"), true); + assert.strictEqual(parseKey("\x1b[69;2u"), "shift+e"); + setKittyProtocolActive(false); + }); + it("should ignore Kitty CSI-u with unsupported modifiers", () => { setKittyProtocolActive(true); assert.strictEqual(parseKey("\x1b[99;17u"), undefined);