feat(coding-agent): publish configuration schemas (#9880)

* feat(coding-agent): publish configuration schemas

* fix(coding-agent): preserve config schema metadata

* fix(coding-agent): align schemas with public contracts

* fix(ai): place compatibility schema with providers

* fix(ai,coding-agent): align configuration schema contracts

* fix(ai): reuse session affinity schema

* docs(coding-agent): document strict theme validation

* fix(ai,coding-agent): align runtime and generated schemas

* fix(ai,coding-agent): centralize configuration schema contracts

* fix(ai,coding-agent): preserve schema validation contracts

* fix(coding-agent): validate keybinding schema grammar

* fix(ai,coding-agent): refine configuration schemas

* fix(ai,tui,coding-agent): align schemas with runtime behavior

* fix(tui): normalize shifted symbol keybindings

* fix(ai): preserve compatibility auto-detection
This commit is contained in:
Christian Klotz 2026-10-08 09:01:17 +01:00 • committed by GitHub
parent 21d1dcd4d4
commit 7f9e1198f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 5177 additions and 1492 deletions

View file

@ -289,6 +289,10 @@ const SYMBOL_KEYS = new Set([
"?",
]);
const SHIFTED_SYMBOL_KEYS = new Set("~!@#$%^&*()_+|{}:<>?");
const MODIFIER_NAMES = ["shift", "alt", "ctrl", "super"] as const;
const MODIFIERS = {
shift: 1,
alt: 2,
@ -365,6 +369,17 @@ function normalizeShiftedLetterIdentityCodepoint(codepoint: number, modifier: nu
return codepoint;
}
function getLogicalShiftedSymbolCandidate(
codepoint: number | undefined,
modifier: number,
allowedSymbols: ReadonlySet<string>,
): { codepoint: number; modifier: number } | undefined {
if (codepoint === undefined || (modifier & MODIFIERS.shift) === 0) return undefined;
const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint);
if (!allowedSymbols.has(String.fromCharCode(normalizedCodepoint))) return undefined;
return { codepoint: normalizedCodepoint, modifier: modifier & ~MODIFIERS.shift };
}
const LEGACY_KEY_SEQUENCES = {
up: ["\x1b[A", "\x1bOA"],
down: ["\x1b[B", "\x1bOB"],
@ -656,9 +671,6 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
const actualMod = parsed.modifier & ~LOCK_MASK;
const expectedMod = expectedModifier & ~LOCK_MASK;
// Check if modifiers match
if (actualMod !== expectedMod) return false;
const normalizedCodepoint = normalizeShiftedLetterIdentityCodepoint(
normalizeKittyFunctionalCodepoint(parsed.codepoint),
parsed.modifier,
@ -668,8 +680,22 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
expectedModifier,
);
// Primary match: codepoint matches directly after normalizing functional keys
if (normalizedCodepoint === normalizedExpectedCodepoint) return true;
// Physical match: primary codepoint and every reported modifier match.
if (actualMod === expectedMod && normalizedCodepoint === normalizedExpectedCodepoint) return true;
// Logical match: Kitty reports symbols such as "+" as Shift plus the physical "=" key.
// Bindings name the produced symbol, so consume that implicit Shift while preserving
// Ctrl, Alt, and Super. The physical "shift+=" spelling remains valid through the match above.
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(parsed.shiftedKey, actualMod, SYMBOL_KEYS);
if (
logicalShiftedSymbol &&
(logicalShiftedSymbol.modifier & ~LOCK_MASK) === expectedMod &&
logicalShiftedSymbol.codepoint === normalizeKittyFunctionalCodepoint(expectedCodepoint)
) {
return true;
}
if (actualMod !== expectedMod) return false;
// Alternate match: use base layout key for non-Latin keyboard layouts.
// This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports
@ -764,13 +790,19 @@ function isDigitKey(key: string): boolean {
}
function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean {
if (expectedModifier === 0) return false;
const parsed = parseModifyOtherKeysSequence(data);
if (!parsed || parsed.modifier !== expectedModifier) return false;
return (
if (!parsed) return false;
const physicalMatch =
parsed.modifier === expectedModifier &&
normalizeShiftedLetterIdentityCodepoint(parsed.codepoint, parsed.modifier) ===
normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier)
normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier);
if (physicalMatch) return true;
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(
parsed.codepoint,
parsed.modifier,
SHIFTED_SYMBOL_KEYS,
);
return logicalShiftedSymbol?.modifier === expectedModifier && logicalShiftedSymbol.codepoint === expectedKeycode;
}
function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined {
@ -788,15 +820,22 @@ function formatKeyNameWithModifiers(keyName: string, modifier: number): string |
function parseKeyId(
keyId: string,
): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null {
const parts = keyId.toLowerCase().split("+");
const key = parts[parts.length - 1];
if (!key) return null;
let key = keyId.toLowerCase();
const modifiers = new Set<(typeof MODIFIER_NAMES)[number]>();
while (true) {
const modifier = MODIFIER_NAMES.find((name) => key.startsWith(`${name}+`));
if (!modifier) break;
if (modifiers.has(modifier)) return null;
modifiers.add(modifier);
key = key.slice(modifier.length + 1);
}
if (!key || (key.includes("+") && key !== "+")) return null;
return {
key,
ctrl: parts.includes("ctrl"),
shift: parts.includes("shift"),
alt: parts.includes("alt"),
super: parts.includes("super"),
ctrl: modifiers.has("ctrl"),
shift: modifiers.has("shift"),
alt: modifiers.has("alt"),
super: modifiers.has("super"),
};
}
@ -1197,7 +1236,9 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
}
// Check both raw char and Kitty sequence (needed for release events)
return data === key || matchesKittySequence(data, codepoint, 0);
return (
data === key || matchesKittySequence(data, codepoint, 0) || matchesPrintableModifyOtherKeys(data, codepoint, 0)
);
}
return false;
@ -1251,12 +1292,25 @@ function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: nu
export function parseKey(data: string): string | undefined {
const kitty = parseKittySequence(data);
if (kitty) {
return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey);
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(kitty.shiftedKey, kitty.modifier, SYMBOL_KEYS);
return formatParsedKey(
logicalShiftedSymbol?.codepoint ?? kitty.codepoint,
logicalShiftedSymbol?.modifier ?? kitty.modifier,
kitty.baseLayoutKey,
);
}
const modifyOtherKeys = parseModifyOtherKeysSequence(data);
if (modifyOtherKeys) {
return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier);
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(
modifyOtherKeys.codepoint,
modifyOtherKeys.modifier,
SHIFTED_SYMBOL_KEYS,
);
return formatParsedKey(
logicalShiftedSymbol?.codepoint ?? modifyOtherKeys.codepoint,
logicalShiftedSymbol?.modifier ?? modifyOtherKeys.modifier,
);
}
// Mode-aware legacy sequences

View file

@ -134,6 +134,16 @@ describe("matchesKey", () => {
setKittyProtocolActive(false);
});
it("should match shifted symbols by their produced character", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[61:43;2u", "+"), true);
assert.strictEqual(matchesKey("\x1b[61:43;2u", "shift+="), true);
assert.strictEqual(parseKey("\x1b[61:43;2u"), "+");
assert.strictEqual(matchesKey("\x1b[61:43;6u", "ctrl++"), true);
assert.strictEqual(parseKey("\x1b[61:43;6u"), "ctrl++");
setKittyProtocolActive(false);
});
it("should handle shifted key in format", () => {
setKittyProtocolActive(true);
// Format with shifted key: CSI codepoint:shifted:base;modifier u
@ -265,6 +275,8 @@ describe("matchesKey", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/");
assert.strictEqual(matchesKey("\x1b[27;6;43~", "ctrl++"), true);
assert.strictEqual(parseKey("\x1b[27;6;43~"), "ctrl++");
});
it("should match xterm modifyOtherKeys digit combos", () => {
@ -600,6 +612,8 @@ describe("parseKey", () => {
assert.strictEqual(parseKey(" "), "space");
assert.strictEqual(parseKey("1"), "1");
assert.strictEqual(matchesKey("1", "1"), true);
assert.strictEqual(parseKey("+"), "+");
assert.strictEqual(matchesKey("+", "+"), true);
});
it("should parse arrow keys", () => {