Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
908 lines
31 KiB
TypeScript
908 lines
31 KiB
TypeScript
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
|
||
import {
|
||
buildKittyKeyboardModeQueryResponse,
|
||
createKittyKeyboardModeState,
|
||
createKittyKeyboardSessionStateStore,
|
||
encodeKittyCompositionText,
|
||
encodeKittyKeyEvent,
|
||
encodeLegacyKeyboardEvent,
|
||
popKittyKeyboardModeFlags,
|
||
pushKittyKeyboardModeFlags,
|
||
restoreKittyKeyboardModeState,
|
||
setKittyKeyboardAlternateScreenActive,
|
||
setKittyKeyboardModeFlags,
|
||
snapshotKittyKeyboardModeState,
|
||
shouldExpectLegacyKeyboardData,
|
||
shouldMarkKittyTextInputEvent,
|
||
shouldTreatKittyAltAsText,
|
||
shouldTrackKittyKeyRelease,
|
||
} from "./kittyKeyboardProtocol";
|
||
import {
|
||
installKittyKeyboardProtocolHandlers,
|
||
installKittyKeyboardProtocolHandlersIfEnabled,
|
||
readKittyKeyboardCsiParam,
|
||
type KittyKeyboardCsiParams,
|
||
} from "./kittyKeyboardRuntime";
|
||
|
||
type CsiHandlerId = { prefix?: string; intermediates?: string; final: string };
|
||
type CsiHandler = (params: KittyKeyboardCsiParams) => boolean;
|
||
const csiKey = (id: CsiHandlerId): string => `${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`;
|
||
|
||
const createFakeCsiParser = () => {
|
||
const handlers = new Map<string, CsiHandler[]>();
|
||
return {
|
||
parser: {
|
||
registerCsiHandler(id: CsiHandlerId, callback: CsiHandler) {
|
||
const key = csiKey(id);
|
||
const list = handlers.get(key) ?? [];
|
||
list.push(callback);
|
||
handlers.set(key, list);
|
||
return { dispose: () => handlers.set(key, (handlers.get(key) ?? []).filter((item) => item !== callback)) };
|
||
},
|
||
registerEscHandler(id: { intermediates?: string; final: string }, callback: () => boolean) {
|
||
const key = `ESC|${id.intermediates ?? ""}|${id.final}`;
|
||
const list = handlers.get(key) ?? [];
|
||
list.push(callback);
|
||
handlers.set(key, list);
|
||
return { dispose: () => handlers.set(key, (handlers.get(key) ?? []).filter((item) => item !== callback)) };
|
||
},
|
||
},
|
||
dispatch(id: CsiHandlerId, params: KittyKeyboardCsiParams = []) {
|
||
const list = handlers.get(csiKey(id));
|
||
assert.ok(list?.length, `missing CSI handler for ${csiKey(id)}`);
|
||
for (let index = list.length - 1; index >= 0; index -= 1) {
|
||
if (list[index](params)) return true;
|
||
}
|
||
return false;
|
||
},
|
||
hasHandler(id: CsiHandlerId) {
|
||
return (handlers.get(csiKey(id))?.length ?? 0) > 0;
|
||
},
|
||
dispatchEsc(id: { intermediates?: string; final: string }) {
|
||
const list = handlers.get(`ESC|${id.intermediates ?? ""}|${id.final}`);
|
||
assert.ok(list?.length, `missing ESC handler for ${id.final}`);
|
||
return list.at(-1)!([]);
|
||
},
|
||
};
|
||
};
|
||
|
||
const withFlags = (flags: number) => {
|
||
const state = createKittyKeyboardModeState();
|
||
setKittyKeyboardModeFlags(state, flags);
|
||
return state;
|
||
};
|
||
|
||
const event = (key: string, overrides: Record<string, unknown> = {}) => ({
|
||
type: "keydown",
|
||
key,
|
||
code: key.length === 1 && /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : key,
|
||
getModifierState: () => false,
|
||
...overrides,
|
||
});
|
||
|
||
test("negotiates all five enhancement flags and masks unknown bits", () => {
|
||
const state = createKittyKeyboardModeState();
|
||
setKittyKeyboardModeFlags(state, 0xff);
|
||
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?31u");
|
||
setKittyKeyboardModeFlags(state, 8, 3);
|
||
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?23u");
|
||
setKittyKeyboardModeFlags(state, 8, 2);
|
||
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?31u");
|
||
});
|
||
|
||
test("maintains bounded independent main and alternate screen stacks", () => {
|
||
const state = createKittyKeyboardModeState();
|
||
setKittyKeyboardModeFlags(state, 1);
|
||
for (let flags = 0; flags < 40; flags += 1) pushKittyKeyboardModeFlags(state, flags);
|
||
assert.equal(state.mainStack.length, 32);
|
||
setKittyKeyboardAlternateScreenActive(state, true);
|
||
setKittyKeyboardModeFlags(state, 16);
|
||
pushKittyKeyboardModeFlags(state, 8);
|
||
assert.equal(popKittyKeyboardModeFlags(state), 16);
|
||
assert.equal(popKittyKeyboardModeFlags(state), 0);
|
||
setKittyKeyboardAlternateScreenActive(state, false);
|
||
assert.notEqual(buildKittyKeyboardModeQueryResponse(state), "\u001b[?16u");
|
||
});
|
||
|
||
test("snapshots and restores negotiated state across renderer handoffs", () => {
|
||
const source = createKittyKeyboardModeState();
|
||
setKittyKeyboardModeFlags(source, 1 | 8);
|
||
pushKittyKeyboardModeFlags(source, 2 | 4);
|
||
setKittyKeyboardAlternateScreenActive(source, true);
|
||
setKittyKeyboardModeFlags(source, 16);
|
||
pushKittyKeyboardModeFlags(source, 1 | 2 | 8);
|
||
|
||
const restored = createKittyKeyboardModeState();
|
||
restoreKittyKeyboardModeState(restored, snapshotKittyKeyboardModeState(source));
|
||
assert.deepEqual(restored, source);
|
||
restored.mainStack.push(31);
|
||
assert.notDeepEqual(restored, source);
|
||
});
|
||
|
||
test("disambiguates ambiguous ASCII and modified control keys", () => {
|
||
const state = withFlags(1);
|
||
assert.equal(encodeKittyKeyEvent(state, event("a")), null);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Escape")), "\u001b[27u");
|
||
assert.equal(encodeKittyKeyEvent(state, event("c", { ctrlKey: true })), "\u001b[99;5u");
|
||
const disambiguate = withFlags(1);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(disambiguate, event("c", {
|
||
ctrlKey: true,
|
||
getModifierState: (name: string) => name === "CapsLock" || name === "NumLock",
|
||
})),
|
||
"\u001b[99;197u",
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("[", { code: "BracketLeft", altKey: true })), "\u001b[91;3u");
|
||
assert.equal(encodeKittyKeyEvent(state, event("Enter", { shiftKey: true })), "\u001b[13;2u");
|
||
});
|
||
|
||
test("alternate-key reporting alone never changes which events are encoded", () => {
|
||
const state = withFlags(4);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Escape")), null);
|
||
assert.equal(encodeKittyKeyEvent(state, event("c", { ctrlKey: true })), null);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Enter", { shiftKey: true })), "\r");
|
||
assert.equal(encodeKittyKeyEvent(state, event("Tab", { shiftKey: true })), "\u001b[Z");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("с", { code: "KeyC", ctrlKey: true })),
|
||
"\u001b[1089::99;5u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("c", { code: "KeyJ", ctrlKey: true })),
|
||
"\u001b[99::106;5u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event(";", { code: "KeyQ", ctrlKey: true })),
|
||
"\u001b[59::113;5u",
|
||
);
|
||
});
|
||
|
||
test("baseline mode preserves Ctrl+Shift combinations that legacy encoding loses", () => {
|
||
const state = withFlags(0);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("I", { code: "KeyI", ctrlKey: true, shiftKey: true })),
|
||
"\u001b[105;6u",
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("i", { ctrlKey: true })), null);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event(" ", { code: "Space", ctrlKey: true, shiftKey: true })),
|
||
"\0",
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("F13")), "\u001b[57376u");
|
||
assert.equal(encodeKittyKeyEvent(state, event("F13", { repeat: true })), "\u001b[57376u");
|
||
assert.equal(encodeKittyKeyEvent(state, event("PrintScreen")), "\u001b[57361u");
|
||
assert.equal(encodeKittyKeyEvent(state, event("ContextMenu")), "\u001b[29~");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("ContextMenu", { repeat: true })),
|
||
"\u001b[29~",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("a", { metaKey: true })),
|
||
"\u001b[97;9u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("i", { ctrlKey: true, shiftKey: true, altKey: true })),
|
||
"\u001b[105;8u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event(" ", {
|
||
code: "Space",
|
||
ctrlKey: true,
|
||
shiftKey: true,
|
||
getModifierState: (name: string) => name === "Hyper",
|
||
})),
|
||
"\u001b[32;22u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Clear", { code: "Numpad5" })),
|
||
"\u001b[E",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Clear", {
|
||
code: "Numpad5",
|
||
applicationCursorMode: true,
|
||
})),
|
||
"\u001bOE",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Clear", {
|
||
code: "Numpad5",
|
||
ctrlKey: true,
|
||
applicationCursorMode: true,
|
||
})),
|
||
"\u001b[1;5E",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Tab", { altKey: true })),
|
||
"\u001b\t",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Tab", { ctrlKey: true, altKey: true })),
|
||
"\u001b\t",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Tab", { shiftKey: true, altKey: true })),
|
||
"\u001b\u001b[Z",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Tab", {
|
||
shiftKey: true,
|
||
ctrlKey: true,
|
||
altKey: true,
|
||
})),
|
||
"\u001b[9;8u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Insert", { shiftKey: true })),
|
||
"\u001b[2;2~",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("PageUp", { altKey: true })),
|
||
"\u001b[5;3~",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("ArrowUp", { metaKey: true })),
|
||
"\u001b[1;9A",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("ArrowUp", {
|
||
getModifierState: (name: string) => name === "Hyper",
|
||
})),
|
||
"\u001b[1;17A",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("ArrowUp", {
|
||
getModifierState: (name: string) => name === "KittyMeta",
|
||
})),
|
||
"\u001b[1;33A",
|
||
);
|
||
const alternateOnly = withFlags(4);
|
||
assert.equal(encodeKittyKeyEvent(alternateOnly, event("F13")), "\u001b[57376u");
|
||
assert.equal(encodeKittyKeyEvent(alternateOnly, event("PrintScreen")), "\u001b[57361u");
|
||
assert.equal(encodeKittyKeyEvent(alternateOnly, event("ContextMenu")), "\u001b[29~");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event("ContextMenu", {
|
||
getModifierState: (name: string) => name === "CapsLock",
|
||
})),
|
||
"\u001b[29;65~",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event("Clear", {
|
||
code: "Numpad5",
|
||
getModifierState: (name: string) => name === "CapsLock",
|
||
})),
|
||
"\u001b[1;65E",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event("ArrowUp", {
|
||
getModifierState: (name: string) => name === "CapsLock",
|
||
})),
|
||
"\u001b[1;65A",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event("ArrowUp", {
|
||
altKey: true,
|
||
getModifierState: (name: string) => name === "CapsLock",
|
||
})),
|
||
"\u001b[1;67A",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event("Insert", {
|
||
shiftKey: true,
|
||
getModifierState: (name: string) => name === "NumLock",
|
||
})),
|
||
"\u001b[2;130~",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event("Tab", {
|
||
altKey: true,
|
||
getModifierState: (name: string) => name === "CapsLock",
|
||
})),
|
||
"\u001b\t",
|
||
);
|
||
for (const [key, expected] of [
|
||
["Enter", "\u001b\r"],
|
||
["Backspace", "\u001b\u007f"],
|
||
["Escape", "\u001b\u001b"],
|
||
] as const) {
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event(key, {
|
||
altKey: true,
|
||
getModifierState: (name: string) => name === "CapsLock",
|
||
})),
|
||
expected,
|
||
);
|
||
}
|
||
for (const flags of [2, 4, 16]) {
|
||
for (const [lock, modifier] of [
|
||
["CapsLock", 69],
|
||
["NumLock", 133],
|
||
] as const) {
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(flags), event("c", {
|
||
code: "KeyC",
|
||
ctrlKey: true,
|
||
getModifierState: (name: string) => name === lock,
|
||
})),
|
||
`\u001b[99;${modifier}u`,
|
||
);
|
||
}
|
||
}
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event("a", {
|
||
code: "KeyA",
|
||
altKey: true,
|
||
getModifierState: (name: string) => name === "CapsLock",
|
||
})),
|
||
"\u001b[97;67u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event(";", {
|
||
code: "Semicolon",
|
||
ctrlKey: true,
|
||
getModifierState: (name: string) => name === "CapsLock",
|
||
})),
|
||
"\u001b[59;69u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(alternateOnly, event(" ", {
|
||
code: "Space",
|
||
ctrlKey: true,
|
||
getModifierState: (name: string) => name === "CapsLock",
|
||
})),
|
||
"\u001b[32;69u",
|
||
);
|
||
});
|
||
|
||
test("legacy fallback preserves text, controls, and application cursor mode", () => {
|
||
assert.equal(encodeLegacyKeyboardEvent(event("a")), "a");
|
||
assert.equal(encodeLegacyKeyboardEvent(event("c", { ctrlKey: true })), "\x03");
|
||
assert.equal(
|
||
encodeLegacyKeyboardEvent(event("3", { code: "Digit3", ctrlKey: true })),
|
||
"\u001b",
|
||
);
|
||
assert.equal(
|
||
encodeLegacyKeyboardEvent(event("c", { ctrlKey: true, altKey: true })),
|
||
"\u001b\x03",
|
||
);
|
||
assert.equal(encodeLegacyKeyboardEvent(event("ArrowUp"), true), "\u001bOA");
|
||
assert.equal(encodeLegacyKeyboardEvent(event("ArrowUp", { ctrlKey: true }), true), "\u001b[1;5A");
|
||
assert.equal(encodeLegacyKeyboardEvent(event("F5")), "\u001b[15~");
|
||
for (const [key, code] of [
|
||
[";", "Semicolon"],
|
||
["'", "Quote"],
|
||
[",", "Comma"],
|
||
[".", "Period"],
|
||
["0", "Digit0"],
|
||
["1", "Digit1"],
|
||
["9", "Digit9"],
|
||
] as const) {
|
||
assert.equal(encodeLegacyKeyboardEvent(event(key, { code, ctrlKey: true })), key);
|
||
}
|
||
assert.equal(
|
||
encodeLegacyKeyboardEvent(event(";", { code: "Semicolon", ctrlKey: true, altKey: true })),
|
||
"\u001b;",
|
||
);
|
||
});
|
||
|
||
test("baseline and event-type press preserve unmapped legacy Ctrl ASCII", () => {
|
||
for (const flags of [0, 2, 4, 16]) {
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(flags), event(";", { code: "Semicolon", ctrlKey: true })),
|
||
";",
|
||
);
|
||
}
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(1), event(";", { code: "Semicolon", ctrlKey: true })),
|
||
"\u001b[59;5u",
|
||
);
|
||
for (const [key, code, expected] of [
|
||
["ж", "Semicolon", ";"],
|
||
["э", "Quote", "'"],
|
||
["б", "Comma", ","],
|
||
["ю", "Period", "."],
|
||
] as const) {
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(0), event(key, { code, ctrlKey: true })),
|
||
expected,
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(2), event(key, { code, ctrlKey: true })),
|
||
expected,
|
||
);
|
||
assert.equal(
|
||
encodeLegacyKeyboardEvent(event(key, { code, ctrlKey: true })),
|
||
expected,
|
||
);
|
||
}
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(0), event("c", { code: "KeyJ", ctrlKey: true })),
|
||
"\x03",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(0), event("c", {
|
||
code: "KeyJ",
|
||
ctrlKey: true,
|
||
altKey: true,
|
||
})),
|
||
"\u001b\x03",
|
||
);
|
||
assert.equal(
|
||
encodeLegacyKeyboardEvent(event("c", { code: "KeyJ", ctrlKey: true })),
|
||
"\x03",
|
||
);
|
||
assert.equal(
|
||
encodeLegacyKeyboardEvent(event("3", { code: "Semicolon", ctrlKey: true })),
|
||
"\u001b",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(0), event("j", { code: "KeyC", ctrlKey: true })),
|
||
"\x0a",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(0), event("j", {
|
||
code: "KeyC",
|
||
ctrlKey: true,
|
||
altKey: true,
|
||
})),
|
||
"\u001b\x0a",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(0), event("a", {
|
||
code: "IntlBackslash",
|
||
keyCode: 226,
|
||
ctrlKey: true,
|
||
})),
|
||
"\x01",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(2), event("a", {
|
||
code: "IntlBackslash",
|
||
keyCode: 226,
|
||
ctrlKey: true,
|
||
altKey: true,
|
||
})),
|
||
"\u001b\x01",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(0), event("ж", {
|
||
code: "IntlBackslash",
|
||
keyCode: 226,
|
||
ctrlKey: true,
|
||
})),
|
||
"\u001b[1078;5u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(2), event("ж", {
|
||
code: "IntlBackslash",
|
||
keyCode: 226,
|
||
ctrlKey: true,
|
||
altKey: true,
|
||
})),
|
||
"\u001b[1078;7u",
|
||
);
|
||
});
|
||
|
||
test("macOS Option text does not depend on the asynchronous layout map", () => {
|
||
assert.equal(shouldTreatKittyAltAsText({ key: "å", altKey: true }, true, false), true);
|
||
assert.equal(shouldTreatKittyAltAsText({ key: "a", altKey: true }, true, false), true);
|
||
assert.equal(shouldTreatKittyAltAsText({ key: "Dead", altKey: true }, true, false), true);
|
||
assert.equal(shouldTreatKittyAltAsText({ key: "ArrowLeft", altKey: true }, true, false), false);
|
||
assert.equal(shouldTreatKittyAltAsText({ key: "å", altKey: true }, true, true), false);
|
||
assert.equal(shouldTreatKittyAltAsText({ key: "Dead", altKey: true }, true, true), false);
|
||
assert.equal(shouldTreatKittyAltAsText({ key: "å", altKey: true }, false, false), false);
|
||
});
|
||
|
||
test("macOS Option-as-Meta encodes physical dead keys while text-producing Option defers", () => {
|
||
const state = withFlags(1 | 2);
|
||
const optionN = event("Dead", {
|
||
code: "KeyN",
|
||
altKey: true,
|
||
altKeyProducesText: false,
|
||
});
|
||
assert.equal(encodeKittyKeyEvent(state, optionN), "\u001b[110;3u");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, { ...optionN, type: "keyup" }),
|
||
"\u001b[110;3:3u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, { ...optionN, altKeyProducesText: true }),
|
||
null,
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, {
|
||
...optionN,
|
||
ctrlKey: true,
|
||
getModifierState: (name: string) => name === "AltGraph",
|
||
}),
|
||
null,
|
||
);
|
||
});
|
||
|
||
test("baseline modes leave shifted AltGraph text to the browser input path", () => {
|
||
const shiftedAltGraph = event("€", {
|
||
code: "KeyE",
|
||
ctrlKey: true,
|
||
altKey: true,
|
||
shiftKey: true,
|
||
getModifierState: (name: string) => name === "AltGraph",
|
||
});
|
||
assert.equal(encodeKittyKeyEvent(withFlags(0), shiftedAltGraph), null);
|
||
assert.equal(encodeKittyKeyEvent(withFlags(4), shiftedAltGraph), null);
|
||
});
|
||
|
||
test("legacy broadcast pairing ignores Meta shortcuts that produce no terminal data", () => {
|
||
assert.equal(shouldExpectLegacyKeyboardData(event("a", { metaKey: true })), false);
|
||
assert.equal(shouldExpectLegacyKeyboardData(event("a")), true);
|
||
assert.equal(shouldExpectLegacyKeyboardData(event("Enter")), true);
|
||
});
|
||
|
||
test("reports press, repeat, and release only for eligible keys", () => {
|
||
const state = withFlags(2);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Escape")), null);
|
||
for (const [overrides, modifier] of [
|
||
[{ shiftKey: true }, 2],
|
||
[{ altKey: true }, 3],
|
||
[{ ctrlKey: true }, 5],
|
||
[{ metaKey: true }, 9],
|
||
[{ getModifierState: (name: string) => name === "CapsLock" }, 65],
|
||
[{ getModifierState: (name: string) => name === "NumLock" }, 129],
|
||
] as const) {
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Escape", overrides)),
|
||
`\u001b[27;${modifier}u`,
|
||
);
|
||
}
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event(" ", {
|
||
code: "Space",
|
||
ctrlKey: true,
|
||
shiftKey: true,
|
||
})),
|
||
"\0",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event(" ", {
|
||
code: "Space",
|
||
ctrlKey: true,
|
||
shiftKey: true,
|
||
repeat: true,
|
||
})),
|
||
"\u001b[32;6:2u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event(" ", {
|
||
type: "keyup",
|
||
code: "Space",
|
||
ctrlKey: true,
|
||
shiftKey: true,
|
||
})),
|
||
"\u001b[32;6:3u",
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("ArrowUp")), "\u001b[A");
|
||
assert.equal(encodeKittyKeyEvent(state, event("ArrowUp", { repeat: true })), "\u001b[1;1:2A");
|
||
assert.equal(encodeKittyKeyEvent(state, event("ArrowUp", { type: "keyup" })), "\u001b[1;1:3A");
|
||
assert.equal(encodeKittyKeyEvent(state, event("a", { repeat: true })), null);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("A", { code: "KeyA", shiftKey: true, repeat: true })),
|
||
null,
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("a", { type: "keyup" })), "\u001b[97;1:3u");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("A", { code: "KeyA", shiftKey: true, type: "keyup" })),
|
||
"\u001b[97;2:3u",
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Enter", { type: "keyup" })), null);
|
||
assert.equal(encodeKittyKeyEvent(state, event("c", { ctrlKey: true })), null);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("c", { ctrlKey: true, repeat: true })),
|
||
"\u001b[99;5:2u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("c", { type: "keyup", ctrlKey: true })),
|
||
"\u001b[99;5:3u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event(" ", { code: "Space", ctrlKey: true })),
|
||
null,
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event(" ", { code: "Space", ctrlKey: true, repeat: true })),
|
||
"\u001b[32;5:2u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event(" ", { code: "Space", ctrlKey: true, type: "keyup" })),
|
||
"\u001b[32;5:3u",
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Enter", { repeat: true })), null);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Tab", { repeat: true })), null);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Backspace", { repeat: true })), null);
|
||
assert.equal(shouldTrackKittyKeyRelease(state, event("c", { ctrlKey: true })), true);
|
||
assert.equal(shouldTrackKittyKeyRelease(state, event("Enter")), false);
|
||
});
|
||
|
||
test("event-type mode matches Kitty's printable-key release behavior", () => {
|
||
const state = withFlags(2);
|
||
assert.equal(encodeKittyKeyEvent(state, event("a")), null);
|
||
assert.equal(encodeKittyKeyEvent(state, event("a", { repeat: true })), null);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("a", { type: "keyup" })),
|
||
"\u001b[97;1:3u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("A", { code: "KeyA", shiftKey: true, type: "keyup" })),
|
||
"\u001b[97;2:3u",
|
||
);
|
||
});
|
||
|
||
test("reports alternate shifted and PC-101 layout key values", () => {
|
||
const state = withFlags(1 | 4);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("+", { code: "Equal", shiftKey: true, ctrlKey: true })),
|
||
"\u001b[61:43;6u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("с", { code: "KeyC", ctrlKey: true })),
|
||
"\u001b[1089::99;5u",
|
||
);
|
||
});
|
||
|
||
test("report-all encodes text, controls, modifiers, repeat, and release", () => {
|
||
const state = withFlags(8);
|
||
assert.equal(encodeKittyKeyEvent(state, event("a")), "\u001b[97u");
|
||
assert.equal(encodeKittyKeyEvent(state, event("Enter")), "\u001b[13u");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Shift", { code: "ShiftLeft", shiftKey: true })),
|
||
"\u001b[57441;2u",
|
||
);
|
||
|
||
setKittyKeyboardModeFlags(state, 2, 2);
|
||
assert.equal(encodeKittyKeyEvent(state, event("a", { repeat: true })), "\u001b[97;1:2u");
|
||
assert.equal(encodeKittyKeyEvent(state, event("a", { type: "keyup" })), "\u001b[97;1:3u");
|
||
assert.equal(encodeKittyKeyEvent(state, event("Enter", { type: "keyup" })), "\u001b[13;1:3u");
|
||
});
|
||
|
||
test("associated text supports multiple code points and pure composition text", () => {
|
||
const state = withFlags(8 | 16);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("A", { code: "KeyA", shiftKey: true })),
|
||
"\u001b[97;2;65u",
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Enter")), "\u001b[13u");
|
||
assert.equal(encodeKittyKeyEvent(state, event("F13")), "\u001b[57376u");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("7", { code: "Numpad7" })),
|
||
"\u001b[57406;;55u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("+", { code: "NumpadAdd" })),
|
||
"\u001b[57413;;43u",
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Dead", { code: "Quote" })), null);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("é", { code: "KeyE" })),
|
||
"\u001b[101;;101:769u",
|
||
);
|
||
assert.equal(encodeKittyCompositionText(state, "你😀"), "\u001b[0;;20320:128512u");
|
||
assert.equal(encodeKittyCompositionText(state, "\n\u0085"), null);
|
||
});
|
||
|
||
test("report-all without associated text leaves composition text for literal fallback", () => {
|
||
assert.equal(encodeKittyCompositionText(withFlags(8), "你"), null);
|
||
assert.equal(encodeKittyCompositionText(withFlags(8), ","), null);
|
||
});
|
||
|
||
test("encodes the complete functional, keypad, media, and modifier ranges", () => {
|
||
const state = withFlags(8);
|
||
const cases = [
|
||
[event("F13"), "\u001b[57376u"],
|
||
[event("F35"), "\u001b[57398u"],
|
||
[event("7", { code: "Numpad7" }), "\u001b[57406u"],
|
||
[event("Home", { code: "Numpad7" }), "\u001b[57423u"],
|
||
[event("Delete", { code: "NumpadDecimal" }), "\u001b[57426u"],
|
||
[event("Clear", { code: "Numpad5" }), "\u001b[57427~"],
|
||
[event(",", { code: "NumpadComma" }), "\u001b[57416u"],
|
||
[event("MediaPlay"), "\u001b[57428u"],
|
||
[event("MediaRecord"), "\u001b[57437u"],
|
||
[event("AudioVolumeMute"), "\u001b[57440u"],
|
||
[event("ContextMenu"), "\u001b[57363u"],
|
||
[event("Control", { code: "ControlRight", ctrlKey: true }), "\u001b[57448;5u"],
|
||
[event("ISOLevel5Shift"), "\u001b[57454u"],
|
||
] as const;
|
||
for (const [input, expected] of cases) assert.equal(encodeKittyKeyEvent(state, input), expected);
|
||
});
|
||
|
||
test("uses official enhanced F-key encodings and covers every F-key code", () => {
|
||
const state = withFlags(8);
|
||
assert.equal(encodeKittyKeyEvent(state, event("F1")), "\u001b[P");
|
||
assert.equal(encodeKittyKeyEvent(state, event("F2")), "\u001b[Q");
|
||
assert.equal(encodeKittyKeyEvent(state, event("F3")), "\u001b[13~");
|
||
assert.equal(encodeKittyKeyEvent(state, event("F4")), "\u001b[S");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("F3", { ctrlKey: true })),
|
||
"\u001b[13;5~",
|
||
);
|
||
const legacyTilde = [15, 17, 18, 19, 20, 21, 23, 24];
|
||
for (let number = 5; number <= 12; number += 1) {
|
||
assert.equal(encodeKittyKeyEvent(state, event(`F${number}`)), `\u001b[${legacyTilde[number - 5]}~`);
|
||
}
|
||
for (let number = 13; number <= 35; number += 1) {
|
||
assert.equal(encodeKittyKeyEvent(state, event(`F${number}`)), `\u001b[${57363 + number}u`);
|
||
}
|
||
});
|
||
|
||
test("uses the F3 tilde form for modified baseline events", () => {
|
||
const state = withFlags(0);
|
||
assert.equal(encodeKittyKeyEvent(state, event("F3", { ctrlKey: true })), "\u001b[13;5~");
|
||
assert.equal(encodeKittyKeyEvent(state, event("F3", { shiftKey: true })), "\u001b[13;2~");
|
||
assert.equal(encodeKittyKeyEvent(state, event("F3", { altKey: true })), "\u001b[13;3~");
|
||
});
|
||
|
||
test("uses application cursor mode only for unmodified cursor keys", () => {
|
||
const state = withFlags(8);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("ArrowUp", { applicationCursorMode: true })),
|
||
"\u001b[A",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Home", { applicationCursorMode: true })),
|
||
"\u001b[H",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("End", { applicationCursorMode: true })),
|
||
"\u001b[F",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("ArrowUp", {
|
||
applicationCursorMode: true,
|
||
ctrlKey: true,
|
||
})),
|
||
"\u001b[1;5A",
|
||
);
|
||
});
|
||
|
||
test("recognizes xterm input-only text without treating paste as a key event", () => {
|
||
assert.equal(shouldMarkKittyTextInputEvent({ data: "😀", inputType: "insertText" }), true);
|
||
assert.equal(shouldMarkKittyTextInputEvent({ data: "hello", inputType: "insertFromPaste" }), false);
|
||
assert.equal(shouldMarkKittyTextInputEvent({ data: null, inputType: "insertText" }), false);
|
||
});
|
||
|
||
test("preserves negotiated state only for renderer hibernation", () => {
|
||
const store = createKittyKeyboardSessionStateStore();
|
||
const sessionOwner = {};
|
||
const initial = store.resolve(sessionOwner, false);
|
||
setKittyKeyboardModeFlags(initial, 31);
|
||
setKittyKeyboardAlternateScreenActive(initial, true);
|
||
pushKittyKeyboardModeFlags(initial, 8);
|
||
|
||
const awakened = store.resolve(sessionOwner, true);
|
||
assert.equal(awakened, initial);
|
||
assert.equal(awakened.alternateScreenActive, true);
|
||
assert.equal(awakened.mainFlags, 31);
|
||
assert.deepEqual(awakened.alternateStack, [0]);
|
||
|
||
const reconnected = store.resolve(sessionOwner, false);
|
||
assert.notEqual(reconnected, initial);
|
||
assert.equal(buildKittyKeyboardModeQueryResponse(reconnected), "\u001b[?0u");
|
||
assert.notEqual(store.resolve({}, true), reconnected);
|
||
setKittyKeyboardModeFlags(reconnected, 8);
|
||
store.reset(sessionOwner);
|
||
assert.equal(buildKittyKeyboardModeQueryResponse(reconnected), "\u001b[?0u");
|
||
});
|
||
|
||
test("includes lock modifiers and excludes associated control text", () => {
|
||
const state = withFlags(8 | 16);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("a", {
|
||
getModifierState: (name: string) => name === "CapsLock" || name === "NumLock",
|
||
})),
|
||
"\u001b[97;193;97u",
|
||
);
|
||
const altGraph = withFlags(1);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(altGraph, event("@", {
|
||
code: "KeyQ",
|
||
ctrlKey: true,
|
||
altKey: true,
|
||
unshiftedKey: "q",
|
||
getModifierState: (name: string) => name === "AltGraph",
|
||
})),
|
||
null,
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(altGraph, event("å", {
|
||
code: "KeyA",
|
||
altKey: true,
|
||
unshiftedKey: "a",
|
||
altKeyProducesText: true,
|
||
})),
|
||
null,
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("c", { ctrlKey: true })), "\u001b[99;5u");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("a", {
|
||
getModifierState: (name: string) => name === "Hyper" || name === "KittyMeta",
|
||
})),
|
||
"\u001b[97;49u",
|
||
);
|
||
assert.equal(encodeKittyKeyEvent(state, event("a", { metaKey: true })), "\u001b[97;9u");
|
||
});
|
||
|
||
test("baseline protocol excludes lock state from Ctrl+Shift disambiguation", () => {
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(0), event("I", {
|
||
code: "KeyI",
|
||
ctrlKey: true,
|
||
shiftKey: true,
|
||
getModifierState: (name: string) => name === "CapsLock" || name === "NumLock",
|
||
})),
|
||
"\u001b[105;6u",
|
||
);
|
||
});
|
||
|
||
test("disambiguation leaves text-producing keypad keys on their text path", () => {
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(1), event("7", { code: "Numpad7" })),
|
||
null,
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(1), event("Home", { code: "Numpad7" })),
|
||
"\u001b[57423u",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(withFlags(1), event("7", {
|
||
code: "Numpad7",
|
||
ctrlKey: true,
|
||
})),
|
||
"\u001b[57406;5u",
|
||
);
|
||
});
|
||
|
||
test("keypad begin preserves baseline and event-type forms", () => {
|
||
const state = withFlags(2);
|
||
assert.equal(encodeKittyKeyEvent(state, event("Clear", { code: "Numpad5" })), "\u001b[E");
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Clear", { code: "Numpad5", repeat: true })),
|
||
"\u001b[1;1:2E",
|
||
);
|
||
assert.equal(
|
||
encodeKittyKeyEvent(state, event("Clear", { code: "Numpad5", type: "keyup" })),
|
||
"\u001b[1;1:3E",
|
||
);
|
||
});
|
||
|
||
test("CSI handlers negotiate, query, stack, and track alternate screen", () => {
|
||
const state = createKittyKeyboardModeState();
|
||
const fake = createFakeCsiParser();
|
||
const replies: string[] = [];
|
||
const disposable = installKittyKeyboardProtocolHandlers(fake.parser, state, (payload) => replies.push(payload));
|
||
|
||
fake.dispatch({ prefix: "=", final: "u" }, [31]);
|
||
fake.dispatch({ prefix: "?", final: "u" });
|
||
assert.equal(replies.at(-1), "\u001b[?31u");
|
||
assert.equal(fake.dispatchEsc({ final: "c" }), false);
|
||
fake.dispatch({ prefix: "?", final: "u" });
|
||
assert.equal(replies.at(-1), "\u001b[?0u");
|
||
fake.dispatch({ prefix: "=", final: "u" }, [31]);
|
||
fake.dispatch({ prefix: ">", final: "u" }, [1]);
|
||
fake.dispatch({ prefix: "<", final: "u" });
|
||
assert.equal(fake.dispatch({ prefix: "?", final: "h" }, [1049]), false);
|
||
fake.dispatch({ prefix: "=", final: "u" }, [8]);
|
||
assert.equal(fake.dispatch({ prefix: "?", final: "l" }, [[1049]]), false);
|
||
fake.dispatch({ prefix: "?", final: "u" });
|
||
assert.equal(replies.at(-1), "\u001b[?31u");
|
||
|
||
disposable.dispose();
|
||
assert.equal(fake.hasHandler({ prefix: "?", final: "u" }), false);
|
||
});
|
||
|
||
test("CSI parser helpers retain explicit opt-in policy", () => {
|
||
assert.equal(readKittyKeyboardCsiParam([], 0, 7), 7);
|
||
assert.equal(readKittyKeyboardCsiParam([[8, 9]], 0, 7), 8);
|
||
const fake = createFakeCsiParser();
|
||
const state = createKittyKeyboardModeState();
|
||
assert.equal(installKittyKeyboardProtocolHandlersIfEnabled(false, fake.parser, state, () => {}), undefined);
|
||
assert.equal(fake.hasHandler({ prefix: "?", final: "u" }), false);
|
||
});
|