import fs from "node:fs";import os from "node:os";import path from "node:path"; import type { KeybindingRule, KeybindingsConfig, KeybindingShortcut, KeybindingWhenNode, ResolvedKeybindingRule, ResolvedKeybindingsConfig,} from "@t3tools/contracts";import { keybindingRuleSchema } from "@t3tools/contracts"; interface KeybindingsLogger { warn(message: string, context?: Record<string, unknown>): void;} type WhenToken = | { type: "identifier"; value: string } | { type: "not" } | { type: "and" } | { type: "or" } | { type: "lparen" } | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" },]; function normalizeKeyToken(token: string): string { if (token === "space") return " "; if (token === "esc") return "escape"; return token;} export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { const rawTokens = value .toLowerCase() .split("+") .map((token) => token.trim()); const tokens = [...rawTokens]; let trailingEmptyCount = 0; while (tokens[tokens.length - 1] === "") { trailingEmptyCount += 1; tokens.pop(); } if (trailingEmptyCount > 0) { tokens.push("+"); } if (tokens.some((token) => token.length === 0)) { return null; } if (tokens.length === 0) return null; let key: string | null = null; let metaKey = false; let ctrlKey = false; let shiftKey = false; let altKey = false; let modKey = false; for (const token of tokens) { switch (token) { case "cmd": case "meta": metaKey = true; break; case "ctrl": case "control": ctrlKey = true; break; case "shift": shiftKey = true; break; case "alt": case "option": altKey = true; break; case "mod": modKey = true; break; default: { if (key !== null) return null; key = normalizeKeyToken(token); } } } if (key === null) return null; return { key, metaKey, ctrlKey, shiftKey, altKey, modKey, };} function tokenizeWhenExpression(expression: string): WhenToken[] | null { const tokens: WhenToken[] = []; let index = 0; while (index < expression.length) { const current = expression[index]; if (!current) break; if (/\s/.test(current)) { index += 1; continue; } if (expression.startsWith("&&", index)) { tokens.push({ type: "and" }); index += 2; continue; } if (expression.startsWith("||", index)) { tokens.push({ type: "or" }); index += 2; continue; } if (current === "!") { tokens.push({ type: "not" }); index += 1; continue; } if (current === "(") { tokens.push({ type: "lparen" }); index += 1; continue; } if (current === ")") { tokens.push({ type: "rparen" }); index += 1; continue; } const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); if (!identifier) { return null; } tokens.push({ type: "identifier", value: identifier[0] }); index += identifier[0].length; } return tokens;} export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { const tokens = tokenizeWhenExpression(expression); if (!tokens || tokens.length === 0) return null; let index = 0; const parsePrimary = (): KeybindingWhenNode | null => { const token = tokens[index]; if (!token) return null; if (token.type === "identifier") { index += 1; return { type: "identifier", name: token.value }; } if (token.type === "lparen") { index += 1; const expressionNode = parseOr(); const closeToken = tokens[index]; if (!expressionNode || !closeToken || closeToken.type !== "rparen") { return null; } index += 1; return expressionNode; } return null; }; const parseUnary = (): KeybindingWhenNode | null => { const token = tokens[index]; if (token?.type === "not") { index += 1; const node = parseUnary(); if (!node) return null; return { type: "not", node }; } return parsePrimary(); }; const parseAnd = (): KeybindingWhenNode | null => { let left = parseUnary(); if (!left) return null; while (tokens[index]?.type === "and") { index += 1; const right = parseUnary(); if (!right) return null; left = { type: "and", left, right }; } return left; }; const parseOr = (): KeybindingWhenNode | null => { let left = parseAnd(); if (!left) return null; while (tokens[index]?.type === "or") { index += 1; const right = parseAnd(); if (!right) return null; left = { type: "or", left, right }; } return left; }; const ast = parseOr(); if (!ast || index !== tokens.length) return null; return ast;} export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { const key = rule.key.trim(); if (key.length === 0) return null; const shortcut = parseKeybindingShortcut(key); if (!shortcut) return null; const when = rule.when?.trim(); if (when && when.length > 0) { const whenAst = parseKeybindingWhenExpression(when); if (!whenAst) return null; return { command: rule.command, shortcut, whenAst, }; } return { command: rule.command, shortcut, };} export function compileResolvedKeybindingsConfig( config: KeybindingsConfig,): ResolvedKeybindingsConfig { const compiled: ResolvedKeybindingsConfig = []; for (const rule of config) { const resolved = compileResolvedKeybindingRule(rule); if (!resolved) continue; compiled.push(resolved); } return compiled;} function compileDefaultKeybindings(): ResolvedKeybindingsConfig { const resolved: ResolvedKeybindingsConfig = []; for (const rule of DEFAULT_KEYBINDINGS) { const compiled = compileResolvedKeybindingRule(rule); if (!compiled) { throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); } resolved.push(compiled); } return resolved;} const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { if (custom.length === 0) { return [...DEFAULT_RESOLVED_KEYBINDINGS]; } const overriddenCommands = new Set(custom.map((binding) => binding.command)); const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( (binding) => !overriddenCommands.has(binding.command), ); const merged = [...retainedDefaults, ...custom]; if (merged.length <= 256) { return merged; } // Keep the latest rules when the config exceeds max size; later rules have higher precedence. return merged.slice(-256);} export function loadResolvedKeybindingsConfig( logger: KeybindingsLogger,): ResolvedKeybindingsConfig { const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); try { const raw = fs.readFileSync(configPath, "utf8"); const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) { logger.warn("ignoring keybindings config with unsupported format; expected array", { path: configPath, }); return [...DEFAULT_RESOLVED_KEYBINDINGS]; } const sanitized: ResolvedKeybindingsConfig = []; let invalidEntries = 0; for (const entry of parsed) { const result = keybindingRuleSchema.safeParse(entry); if (result.success) { const compiled = compileResolvedKeybindingRule(result.data); if (!compiled) { invalidEntries += 1; continue; } sanitized.push(compiled); continue; } invalidEntries += 1; } if (invalidEntries > 0) { logger.warn("ignoring invalid keybinding entries", { path: configPath, invalidEntries, totalEntries: parsed.length, }); } const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); const mergedBeforeCapLength = DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) .length + sanitized.length; const merged = mergeWithDefaultKeybindings(sanitized); if (mergedBeforeCapLength > 256) { logger.warn("truncating merged keybindings config to max entries", { path: configPath, maxEntries: 256, }); } return merged; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { return [...DEFAULT_RESOLVED_KEYBINDINGS]; } logger.warn("ignoring malformed keybindings config", { path: configPath, error: error instanceof Error ? error.message : String(error), }); } return [...DEFAULT_RESOLVED_KEYBINDINGS];}
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type {
KeybindingRule,
KeybindingsConfig,
KeybindingShortcut,
KeybindingWhenNode,
ResolvedKeybindingRule,
ResolvedKeybindingsConfig,
} from "@t3tools/contracts";
import { keybindingRuleSchema } from "@t3tools/contracts";
interface KeybindingsLogger {
warn(message: string, context?: Record<string, unknown>): void;
}
type WhenToken =
| { type: "identifier"; value: string }
| { type: "not" }
| { type: "and" }
| { type: "or" }
| { type: "lparen" }
| { type: "rparen" };
export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+j", command: "terminal.toggle" },
{ key: "mod+d", command: "terminal.split", when: "terminalFocus" },
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
{ key: "mod+o", command: "editor.openFavorite" },
];
function normalizeKeyToken(token: string): string {
if (token === "space") return " ";
if (token === "esc") return "escape";
return token;
}
export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {
const rawTokens = value
.toLowerCase()
.split("+")
.map((token) => token.trim());
const tokens = [...rawTokens];
let trailingEmptyCount = 0;
while (tokens[tokens.length - 1] === "") {
trailingEmptyCount += 1;
tokens.pop();
}
if (trailingEmptyCount > 0) {
tokens.push("+");
}
if (tokens.some((token) => token.length === 0)) {
return null;
}
if (tokens.length === 0) return null;
let key: string | null = null;
let metaKey = false;
let ctrlKey = false;
let shiftKey = false;
let altKey = false;
let modKey = false;
for (const token of tokens) {
switch (token) {
case "cmd":
case "meta":
metaKey = true;
break;
case "ctrl":
case "control":
ctrlKey = true;
break;
case "shift":
shiftKey = true;
break;
case "alt":
case "option":
altKey = true;
break;
case "mod":
modKey = true;
break;
default: {
if (key !== null) return null;
key = normalizeKeyToken(token);
}
}
}
if (key === null) return null;
return {
key,
metaKey,
ctrlKey,
shiftKey,
altKey,
modKey,
};
}
function tokenizeWhenExpression(expression: string): WhenToken[] | null {
const tokens: WhenToken[] = [];
let index = 0;
while (index < expression.length) {
const current = expression[index];
if (!current) break;
if (/\s/.test(current)) {
index += 1;
continue;
}
if (expression.startsWith("&&", index)) {
tokens.push({ type: "and" });
index += 2;
continue;
}
if (expression.startsWith("||", index)) {
tokens.push({ type: "or" });
index += 2;
continue;
}
if (current === "!") {
tokens.push({ type: "not" });
index += 1;
continue;
}
if (current === "(") {
tokens.push({ type: "lparen" });
index += 1;
continue;
}
if (current === ")") {
tokens.push({ type: "rparen" });
index += 1;
continue;
}
const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
if (!identifier) {
return null;
}
tokens.push({ type: "identifier", value: identifier[0] });
index += identifier[0].length;
}
return tokens;
}
export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
const tokens = tokenizeWhenExpression(expression);
if (!tokens || tokens.length === 0) return null;
let index = 0;
const parsePrimary = (): KeybindingWhenNode | null => {
const token = tokens[index];
if (!token) return null;
if (token.type === "identifier") {
index += 1;
return { type: "identifier", name: token.value };
}
if (token.type === "lparen") {
index += 1;
const expressionNode = parseOr();
const closeToken = tokens[index];
if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
return null;
}
index += 1;
return expressionNode;
}
return null;
};
const parseUnary = (): KeybindingWhenNode | null => {
const token = tokens[index];
if (token?.type === "not") {
index += 1;
const node = parseUnary();
if (!node) return null;
return { type: "not", node };
}
return parsePrimary();
};
const parseAnd = (): KeybindingWhenNode | null => {
let left = parseUnary();
if (!left) return null;
while (tokens[index]?.type === "and") {
index += 1;
const right = parseUnary();
if (!right) return null;
left = { type: "and", left, right };
}
return left;
};
const parseOr = (): KeybindingWhenNode | null => {
let left = parseAnd();
if (!left) return null;
while (tokens[index]?.type === "or") {
index += 1;
const right = parseAnd();
if (!right) return null;
left = { type: "or", left, right };
}
return left;
};
const ast = parseOr();
if (!ast || index !== tokens.length) return null;
return ast;
}
export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {
const key = rule.key.trim();
if (key.length === 0) return null;
const shortcut = parseKeybindingShortcut(key);
if (!shortcut) return null;
const when = rule.when?.trim();
if (when && when.length > 0) {
const whenAst = parseKeybindingWhenExpression(when);
if (!whenAst) return null;
return {
command: rule.command,
shortcut,
whenAst,
};
}
return {
command: rule.command,
shortcut,
};
}
export function compileResolvedKeybindingsConfig(
config: KeybindingsConfig,
): ResolvedKeybindingsConfig {
const compiled: ResolvedKeybindingsConfig = [];
for (const rule of config) {
const resolved = compileResolvedKeybindingRule(rule);
if (!resolved) continue;
compiled.push(resolved);
}
return compiled;
}
function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
const resolved: ResolvedKeybindingsConfig = [];
for (const rule of DEFAULT_KEYBINDINGS) {
const compiled = compileResolvedKeybindingRule(rule);
if (!compiled) {
throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`);
}
resolved.push(compiled);
}
return resolved;
}
const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
if (custom.length === 0) {
return [...DEFAULT_RESOLVED_KEYBINDINGS];
}
const overriddenCommands = new Set(custom.map((binding) => binding.command));
const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
(binding) => !overriddenCommands.has(binding.command),
);
const merged = [...retainedDefaults, ...custom];
if (merged.length <= 256) {
return merged;
}
// Keep the latest rules when the config exceeds max size; later rules have higher precedence.
return merged.slice(-256);
}
export function loadResolvedKeybindingsConfig(
logger: KeybindingsLogger,
): ResolvedKeybindingsConfig {
const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
try {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
logger.warn("ignoring keybindings config with unsupported format; expected array", {
path: configPath,
});
return [...DEFAULT_RESOLVED_KEYBINDINGS];
}
const sanitized: ResolvedKeybindingsConfig = [];
let invalidEntries = 0;
for (const entry of parsed) {
const result = keybindingRuleSchema.safeParse(entry);
if (result.success) {
const compiled = compileResolvedKeybindingRule(result.data);
if (!compiled) {
invalidEntries += 1;
continue;
}
sanitized.push(compiled);
continue;
}
invalidEntries += 1;
}
if (invalidEntries > 0) {
logger.warn("ignoring invalid keybinding entries", {
path: configPath,
invalidEntries,
totalEntries: parsed.length,
});
}
const overriddenCommands = new Set(sanitized.map((entry) => entry.command));
const mergedBeforeCapLength =
DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
.length + sanitized.length;
const merged = mergeWithDefaultKeybindings(sanitized);
if (mergedBeforeCapLength > 256) {
logger.warn("truncating merged keybindings config to max entries", {
path: configPath,
maxEntries: 256,
});
}
return merged;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return [...DEFAULT_RESOLVED_KEYBINDINGS];
}
logger.warn("ignoring malformed keybindings config", {
path: configPath,
error: error instanceof Error ? error.message : String(error),
});
}
return [...DEFAULT_RESOLVED_KEYBINDINGS];
}