blob: 82ff1586a5eaed4f1ee142b4c9422c1384504f14 [file]
export const TERMINAL_INPUT_NAMED_KEYS = [
'enter',
'escape',
'tab',
'backspace',
'delete',
'arrow_up',
'arrow_down',
'arrow_left',
'arrow_right',
'home',
'end',
'insert',
'page_up',
'page_down',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12',
] as const;
export const TERMINAL_INPUT_MODIFIERS = ['ctrl', 'alt', 'shift'] as const;
export const TERMINAL_MOUSE_BUTTONS = ['left', 'middle', 'right'] as const;
export const TERMINAL_MOUSE_EVENTS = ['click', 'press', 'release', 'move', 'scroll'] as const;
export const TERMINAL_MOUSE_SCROLL_DIRECTIONS = ['up', 'down'] as const;
export type TerminalInputNamedKey = (typeof TERMINAL_INPUT_NAMED_KEYS)[number];
export type TerminalInputModifier = (typeof TERMINAL_INPUT_MODIFIERS)[number];
export type TerminalMouseButton = (typeof TERMINAL_MOUSE_BUTTONS)[number];
export type TerminalMouseEvent = (typeof TERMINAL_MOUSE_EVENTS)[number];
export type TerminalMouseScrollDirection = (typeof TERMINAL_MOUSE_SCROLL_DIRECTIONS)[number];
export type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any';
export type TerminalMouseEncoding = 'default' | 'sgr' | 'sgr_pixels';
export interface TerminalTextInputAction {
readonly type: 'text';
readonly text: string;
}
export interface TerminalKeyInputAction {
readonly type: 'key';
readonly key: TerminalInputNamedKey | string;
readonly modifiers?: readonly TerminalInputModifier[];
}
interface TerminalMouseInputActionBase {
readonly type: 'mouse';
readonly x: number;
readonly y: number;
readonly modifiers?: readonly TerminalInputModifier[];
}
export type TerminalMouseInputAction =
| (TerminalMouseInputActionBase & {
readonly event: 'click' | 'press' | 'release';
readonly button: TerminalMouseButton;
readonly direction?: never;
})
| (TerminalMouseInputActionBase & {
readonly event: 'move';
readonly button?: TerminalMouseButton;
readonly direction?: never;
})
| (TerminalMouseInputActionBase & {
readonly event: 'scroll';
readonly button?: never;
readonly direction: TerminalMouseScrollDirection;
});
export type TerminalInputAction =
| TerminalTextInputAction
| TerminalKeyInputAction
| TerminalMouseInputAction;
export interface TerminalInputModes {
readonly applicationCursorKeysMode: boolean;
}
export interface TerminalInputState extends TerminalInputModes {
readonly mouseTrackingMode: TerminalMouseTrackingMode;
readonly mouseEncoding: TerminalMouseEncoding;
readonly cols: number;
readonly rows: number;
}
const NAMED_KEY_SET = new Set<string>(TERMINAL_INPUT_NAMED_KEYS);
const MODIFIER_SET = new Set<string>(TERMINAL_INPUT_MODIFIERS);
const MOUSE_BUTTON_SET = new Set<string>(TERMINAL_MOUSE_BUTTONS);
const MOUSE_EVENT_SET = new Set<string>(TERMINAL_MOUSE_EVENTS);
const MOUSE_SCROLL_DIRECTION_SET = new Set<string>(TERMINAL_MOUSE_SCROLL_DIRECTIONS);
export function isTerminalInputNamedKey(value: string): value is TerminalInputNamedKey {
return NAMED_KEY_SET.has(value);
}
export function isTerminalInputModifier(value: string): value is TerminalInputModifier {
return MODIFIER_SET.has(value);
}
export function isTerminalCharacterKey(value: string): boolean {
return value.length === 1 && value.charCodeAt(0) >= 0x20 && value.charCodeAt(0) <= 0x7e;
}
export function isWellFormedTerminalInput(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
if (index + 1 >= value.length) return false;
const next = value.charCodeAt(index + 1);
if (next < 0xdc00 || next > 0xdfff) return false;
index += 1;
} else if (code >= 0xdc00 && code <= 0xdfff) {
return false;
}
}
return true;
}
export function parseTerminalInputAction(value: unknown): TerminalInputAction {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Terminal input action must be an object');
}
const action = value as Record<string, unknown>;
if (action.type === 'text') {
assertOnlyActionFields(action, ['type', 'text']);
if (typeof action.text !== 'string' || action.text.length === 0) {
throw new Error('Terminal text action must contain text');
}
if (!isWellFormedTerminalInput(action.text)) {
throw new Error('Terminal text action must be well-formed Unicode');
}
if (hasTerminalControlCharacter(action.text)) {
throw new Error('Terminal text action cannot contain terminal control characters');
}
return { type: 'text', text: action.text };
}
if (action.type === 'mouse') return parseMouseAction(action);
if (action.type !== 'key') {
throw new Error('Terminal input action type must be text, key, or mouse');
}
assertOnlyActionFields(action, ['type', 'key', 'modifiers']);
if (typeof action.key !== 'string') throw new Error('Terminal key must be a string');
if (!isTerminalInputNamedKey(action.key) && !isTerminalCharacterKey(action.key)) {
throw new Error('Terminal key must be a supported named key or printable ASCII character');
}
const modifiers = parseModifiers(action.modifiers, 'key');
const parsed: TerminalKeyInputAction = {
type: 'key',
key: action.key,
...(modifiers && modifiers.length > 0
? { modifiers: modifiers as TerminalInputModifier[] }
: {}),
};
encodeTerminalInputAction(parsed, { applicationCursorKeysMode: false });
return parsed;
}
export function normalizeTerminalInputActionDefaults(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
const normalized = { ...(value as Record<string, unknown>) };
if (normalized.type === 'text') {
if (normalized.key === '' || normalized.key === null) delete normalized.key;
deleteProviderDefaults(normalized, ['event', 'x', 'y', 'button', 'direction']);
} else if (normalized.type === 'key') {
if (normalized.text === '' || normalized.text === null) delete normalized.text;
deleteProviderDefaults(normalized, ['event', 'x', 'y', 'button', 'direction']);
} else if (normalized.type === 'mouse') {
deleteProviderDefaults(normalized, ['text', 'key']);
if (normalized.event === 'scroll') deleteProviderDefaults(normalized, ['button']);
else deleteProviderDefaults(normalized, ['direction']);
}
if (normalized.modifiers === null || isEmptyArray(normalized.modifiers)) {
delete normalized.modifiers;
}
return normalized;
}
export function encodeTerminalInputActions(
actions: readonly TerminalInputAction[],
state: TerminalInputModes | TerminalInputState,
): string {
return actions.map((action) => encodeTerminalInputAction(action, state)).join('');
}
export function encodedTerminalInputActionsByteLength(
actions: readonly TerminalInputAction[],
): number {
const encoded = actions
.map((action) => {
if (action.type === 'mouse') return encodeSgrTerminalMouseInputAction(action);
return encodeTerminalInputAction(action, { applicationCursorKeysMode: false });
})
.join('');
return new TextEncoder().encode(encoded).byteLength;
}
export function formatTerminalInputActions(actions: readonly TerminalInputAction[]): string {
return actions.map(formatTerminalInputAction).join(' → ');
}
function encodeTerminalInputAction(
action: TerminalInputAction,
state: TerminalInputModes | TerminalInputState,
): string {
if (action.type === 'text') return action.text;
if (action.type === 'mouse') {
return encodeTerminalMouseInputAction(action, requireTerminalInputState(state));
}
const modifiers = new Set(action.modifiers ?? []);
if (isTerminalInputNamedKey(action.key)) {
return encodeNamedKey(action.key, modifiers, state);
}
return encodeCharacterKey(action.key, modifiers);
}
function encodeCharacterKey(key: string, modifiers: ReadonlySet<TerminalInputModifier>): string {
if (!isTerminalCharacterKey(key)) throw new Error(`Unsupported terminal character key: ${key}`);
if (modifiers.has('shift')) {
throw new Error('Use the shifted character directly instead of the shift modifier');
}
let encoded = modifiers.has('ctrl') ? encodeCtrlCharacter(key) : key;
if (modifiers.has('alt')) encoded = `\u001b${encoded}`;
return encoded;
}
function encodeCtrlCharacter(key: string): string {
const code = key.charCodeAt(0);
if (key === ' ' || key === '@') return '\u0000';
if ((code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)) {
return String.fromCharCode(code & 0x1f);
}
switch (key) {
case '[':
return '\u001b';
case '\\':
return '\u001c';
case ']':
return '\u001d';
case '^':
return '\u001e';
case '_':
return '\u001f';
case '?':
return '\u007f';
default:
throw new Error(`Ctrl-${key} has no portable terminal encoding`);
}
}
function encodeNamedKey(
key: TerminalInputNamedKey,
modifiers: ReadonlySet<TerminalInputModifier>,
modes: TerminalInputModes,
): string {
if (modifiers.size === 0) return unmodifiedNamedKey(key, modes);
if (key === 'tab' && modifiers.size === 1 && modifiers.has('shift')) return '\u001b[Z';
if (['enter', 'escape', 'tab', 'backspace'].includes(key)) {
throw new Error(`${formatNamedKey(key)} does not support these modifiers`);
}
const parameter = xtermModifierParameter(modifiers);
const final = cursorKeyFinal(key);
if (final) return `\u001b[1;${parameter}${final}`;
const tilde = tildeKeyParameter(key);
if (tilde !== undefined) return `\u001b[${tilde};${parameter}~`;
const functionFinal = functionKeyFinal(key);
if (functionFinal) return `\u001b[1;${parameter}${functionFinal}`;
throw new Error(`Unsupported modified terminal key: ${key}`);
}
function unmodifiedNamedKey(key: TerminalInputNamedKey, modes: TerminalInputModes): string {
switch (key) {
case 'enter':
return '\r';
case 'escape':
return '\u001b';
case 'tab':
return '\t';
case 'backspace':
return '\u007f';
}
const final = cursorKeyFinal(key);
if (final) return `\u001b${modes.applicationCursorKeysMode ? 'O' : '['}${final}`;
const tilde = tildeKeyParameter(key);
if (tilde !== undefined) return `\u001b[${tilde}~`;
const functionFinal = functionKeyFinal(key);
if (functionFinal) return `\u001bO${functionFinal}`;
throw new Error(`Unsupported terminal key: ${key}`);
}
function cursorKeyFinal(key: TerminalInputNamedKey): string | undefined {
switch (key) {
case 'arrow_up':
return 'A';
case 'arrow_down':
return 'B';
case 'arrow_right':
return 'C';
case 'arrow_left':
return 'D';
case 'home':
return 'H';
case 'end':
return 'F';
default:
return undefined;
}
}
function tildeKeyParameter(key: TerminalInputNamedKey): number | undefined {
switch (key) {
case 'insert':
return 2;
case 'delete':
return 3;
case 'page_up':
return 5;
case 'page_down':
return 6;
case 'f5':
return 15;
case 'f6':
return 17;
case 'f7':
return 18;
case 'f8':
return 19;
case 'f9':
return 20;
case 'f10':
return 21;
case 'f11':
return 23;
case 'f12':
return 24;
default:
return undefined;
}
}
function functionKeyFinal(key: TerminalInputNamedKey): string | undefined {
switch (key) {
case 'f1':
return 'P';
case 'f2':
return 'Q';
case 'f3':
return 'R';
case 'f4':
return 'S';
default:
return undefined;
}
}
function xtermModifierParameter(modifiers: ReadonlySet<TerminalInputModifier>): number {
return (
1 +
(modifiers.has('shift') ? 1 : 0) +
(modifiers.has('alt') ? 2 : 0) +
(modifiers.has('ctrl') ? 4 : 0)
);
}
function formatTerminalInputAction(action: TerminalInputAction): string {
if (action.type === 'text') return JSON.stringify(action.text);
if (action.type === 'mouse') return formatTerminalMouseInputAction(action);
const key = isTerminalInputNamedKey(action.key)
? formatNamedKey(action.key)
: action.key.toUpperCase();
const modifiers = new Set(action.modifiers ?? []);
const prefix = [
...(modifiers.has('ctrl') ? ['Ctrl'] : []),
...(modifiers.has('alt') ? ['Alt'] : []),
...(modifiers.has('shift') ? ['Shift'] : []),
];
return [...prefix, key].join('-');
}
function parseMouseAction(action: Record<string, unknown>): TerminalMouseInputAction {
assertOnlyActionFields(action, ['type', 'event', 'x', 'y', 'button', 'direction', 'modifiers']);
if (typeof action.event !== 'string' || !MOUSE_EVENT_SET.has(action.event)) {
throw new Error('Terminal mouse event is not supported');
}
if (!Number.isSafeInteger(action.x) || (action.x as number) < 0) {
throw new Error('Terminal mouse x must be a non-negative integer');
}
if (!Number.isSafeInteger(action.y) || (action.y as number) < 0) {
throw new Error('Terminal mouse y must be a non-negative integer');
}
const event = action.event as TerminalMouseEvent;
const button = parseMouseButton(action.button);
const direction = parseMouseDirection(action.direction);
const modifiers = parseModifiers(action.modifiers, 'mouse');
const base = {
type: 'mouse',
x: action.x as number,
y: action.y as number,
...(modifiers && modifiers.length > 0 ? { modifiers } : {}),
} as const;
if (event === 'scroll') {
if (!direction) throw new Error('Terminal mouse scroll requires a direction');
if (button) throw new Error('Terminal mouse scroll does not accept a button');
return { ...base, event, direction };
}
if (direction) throw new Error(`Terminal mouse ${event} does not accept a direction`);
if (event === 'move') return { ...base, event, ...(button ? { button } : {}) };
if (!button) throw new Error(`Terminal mouse ${event} requires a button`);
return { ...base, event, button };
}
function parseModifiers(
value: unknown,
actionKind: 'key' | 'mouse',
): TerminalInputModifier[] | undefined {
if (value !== undefined && !Array.isArray(value)) {
throw new Error(`Terminal ${actionKind} modifiers must be an array`);
}
const modifiers = value as unknown[] | undefined;
if (
modifiers?.some(
(modifier) => typeof modifier !== 'string' || !isTerminalInputModifier(modifier),
)
) {
throw new Error(`Terminal ${actionKind} modifier is not supported`);
}
if (modifiers && new Set(modifiers).size !== modifiers.length) {
throw new Error(`Terminal ${actionKind} modifiers must be unique`);
}
return modifiers as TerminalInputModifier[] | undefined;
}
function parseMouseButton(value: unknown): TerminalMouseButton | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || !MOUSE_BUTTON_SET.has(value)) {
throw new Error('Terminal mouse button is not supported');
}
return value as TerminalMouseButton;
}
function parseMouseDirection(value: unknown): TerminalMouseScrollDirection | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || !MOUSE_SCROLL_DIRECTION_SET.has(value)) {
throw new Error('Terminal mouse scroll direction is not supported');
}
return value as TerminalMouseScrollDirection;
}
function requireTerminalInputState(
state: TerminalInputModes | TerminalInputState,
): TerminalInputState {
if (
!('mouseTrackingMode' in state) ||
!('mouseEncoding' in state) ||
!('cols' in state) ||
!('rows' in state)
) {
throw new Error('Terminal mouse input requires live terminal state');
}
return state;
}
function formatNamedKey(key: TerminalInputNamedKey): string {
switch (key) {
case 'arrow_up':
return 'Up';
case 'arrow_down':
return 'Down';
case 'arrow_left':
return 'Left';
case 'arrow_right':
return 'Right';
case 'page_up':
return 'PageUp';
case 'page_down':
return 'PageDown';
default:
return key.length === 2 && key.startsWith('f')
? key.toUpperCase()
: `${key[0]?.toUpperCase()}${key.slice(1)}`;
}
}
function assertOnlyActionFields(
action: Readonly<Record<string, unknown>>,
allowed: readonly string[],
): void {
const unsupported = Object.keys(action).find((field) => !allowed.includes(field));
if (unsupported) throw new Error(`Terminal input action has unsupported field: ${unsupported}`);
}
function deleteProviderDefaults(value: Record<string, unknown>, fields: readonly string[]): void {
for (const field of fields) {
if (value[field] === '' || value[field] === null || value[field] === 0) delete value[field];
}
}
function hasTerminalControlCharacter(value: string): boolean {
for (const char of value) {
const codePoint = char.codePointAt(0) ?? 0;
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return true;
}
return false;
}
function isEmptyArray(value: unknown): value is [] {
return Array.isArray(value) && value.length === 0;
}
import {
encodeSgrTerminalMouseInputAction,
encodeTerminalMouseInputAction,
formatTerminalMouseInputAction,
} from './terminal-mouse-input.js';