fix(tui): restore shifted xterm input

closes #3436
This commit is contained in:
Mario Zechner
2026-04-20 15:36:10 +02:00
parent da6a81d398
commit 6b55d6859d
6 changed files with 106 additions and 14 deletions

View File

@@ -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))

View File

@@ -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

View File

@@ -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;
}

View File

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

View File

@@ -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", () => {

View File

@@ -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);