apps/server/src/keybindings.ts
/**
* Keybindings - Keybinding configuration service definitions.
*
* Owns parsing, validation, merge, and persistence of user keybinding
* configuration consumed by the server runtime.
*
* @module Keybindings
*/
import {
KeybindingRule,
KeybindingsConfig,
KeybindingShortcut,
KeybindingWhenNode,
MAX_KEYBINDINGS_COUNT,
MAX_WHEN_EXPRESSION_DEPTH,
ResolvedKeybindingRule,
ResolvedKeybindingsConfig,
type ServerConfigIssue,
} from "@t3tools/contracts";
import { Mutable } from "effect/Types";
import {
Array,
Cache,
Cause,
Effect,
FileSystem,
Path,
Layer,
Option,
Predicate,
PubSub,
Schema,
SchemaGetter,
SchemaIssue,
SchemaTransformation,
ServiceMap,
Stream,
} from "effect";
import * as Semaphore from "effect/Semaphore";
import { ServerConfig } from "./config";
export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()(
"KeybindingsConfigParseError",
{
configPath: Schema.String,
detail: Schema.String,
cause: Schema.optional(Schema.Defect),
},
) {
override get message(): string {
return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
}
}
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+d", command: "diff.toggle", 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;
}
/** @internal - Exported for testing */
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;
}
function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
const tokens = tokenizeWhenExpression(expression);
if (!tokens || tokens.length === 0) return null;
let index = 0;
const parsePrimary = (depth: number): KeybindingWhenNode | null => {
if (depth > MAX_WHEN_EXPRESSION_DEPTH) {
return 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(depth + 1);
const closeToken = tokens[index];
if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
return null;
}
index += 1;
return expressionNode;
}
return null;
};
const parseUnary = (depth: number): KeybindingWhenNode | null => {
let notCount = 0;
while (tokens[index]?.type === "not") {
index += 1;
notCount += 1;
if (notCount > MAX_WHEN_EXPRESSION_DEPTH) {
return null;
}
}
let node = parsePrimary(depth);
if (!node) return null;
while (notCount > 0) {
node = { type: "not", node };
notCount -= 1;
}
return node;
};
const parseAnd = (depth: number): KeybindingWhenNode | null => {
let left = parseUnary(depth);
if (!left) return null;
while (tokens[index]?.type === "and") {
index += 1;
const right = parseUnary(depth);
if (!right) return null;
left = { type: "and", left, right };
}
return left;
};
const parseOr = (depth: number): KeybindingWhenNode | null => {
let left = parseAnd(depth);
if (!left) return null;
while (tokens[index]?.type === "or") {
index += 1;
const right = parseAnd(depth);
if (!right) return null;
left = { type: "or", left, right };
}
return left;
};
const ast = parseOr(0);
if (!ast || index !== tokens.length) return null;
return ast;
}
/** @internal - Exported for testing */
export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {
const shortcut = parseKeybindingShortcut(rule.key);
if (!shortcut) return null;
if (rule.when !== undefined) {
const whenAst = parseKeybindingWhenExpression(rule.when);
if (!whenAst) return null;
return {
command: rule.command,
shortcut,
whenAst,
};
}
return {
command: rule.command,
shortcut,
};
}
export function compileResolvedKeybindingsConfig(
config: KeybindingsConfig,
): ResolvedKeybindingsConfig {
const compiled: Mutable<ResolvedKeybindingsConfig> = [];
for (const rule of config) {
const result = Schema.decodeExit(ResolvedKeybindingFromConfig)(rule);
if (result._tag === "Success") {
compiled.push(result.value);
}
}
return compiled;
}
export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(
Schema.decodeTo(
Schema.toType(ResolvedKeybindingRule),
SchemaTransformation.transformOrFail({
decode: (rule) =>
Effect.succeed(compileResolvedKeybindingRule(rule)).pipe(
Effect.filterOrFail(
Predicate.isNotNull,
() =>
new SchemaIssue.InvalidValue(Option.some(rule), {
title: "Invalid keybinding rule",
}),
),
Effect.map((resolved) => resolved),
),
encode: (resolved) =>
Effect.gen(function* () {
const key = encodeShortcut(resolved.shortcut);
if (!key) {
return yield* Effect.fail(
new SchemaIssue.InvalidValue(Option.some(resolved), {
title: "Resolved shortcut cannot be encoded to key string",
}),
);
}
const when = resolved.whenAst ? encodeWhenAst(resolved.whenAst) : undefined;
return {
key,
command: resolved.command,
when,
};
}),
}),
),
);
export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFromConfig).check(
Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
);
function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean {
return (
left.command === right.command &&
left.key === right.key &&
(left.when ?? undefined) === (right.when ?? undefined)
);
}
function keybindingShortcutContext(rule: KeybindingRule): string | null {
const parsed = parseKeybindingShortcut(rule.key);
if (!parsed) return null;
const encoded = encodeShortcut(parsed);
if (!encoded) return null;
return `${encoded}\u0000${rule.when ?? ""}`;
}
function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): boolean {
const leftContext = keybindingShortcutContext(left);
const rightContext = keybindingShortcutContext(right);
if (!leftContext || !rightContext) return false;
return leftContext === rightContext;
}
function encodeShortcut(shortcut: KeybindingShortcut): string | null {
const modifiers: string[] = [];
if (shortcut.modKey) modifiers.push("mod");
if (shortcut.metaKey) modifiers.push("meta");
if (shortcut.ctrlKey) modifiers.push("ctrl");
if (shortcut.altKey) modifiers.push("alt");
if (shortcut.shiftKey) modifiers.push("shift");
if (!shortcut.key) return null;
if (shortcut.key !== "+" && shortcut.key.includes("+")) return null;
const key = shortcut.key === " " ? "space" : shortcut.key;
return [...modifiers, key].join("+");
}
function encodeWhenAst(node: KeybindingWhenNode): string {
switch (node.type) {
case "identifier":
return node.name;
case "not":
return `!(${encodeWhenAst(node.node)})`;
case "and":
return `(${encodeWhenAst(node.left)} && ${encodeWhenAst(node.right)})`;
case "or":
return `(${encodeWhenAst(node.left)} || ${encodeWhenAst(node.right)})`;
}
}
const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown));
const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
const PrettyJsonString = SchemaGetter.parseJson<string>().compose(
SchemaGetter.stringifyJson({ space: 2 }),
);
const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe(
Schema.encode({
decode: PrettyJsonString,
encode: PrettyJsonString,
}),
);
export interface KeybindingsConfigState {
readonly keybindings: ResolvedKeybindingsConfig;
readonly issues: readonly ServerConfigIssue[];
}
export interface KeybindingsChangeEvent {
readonly issues: readonly ServerConfigIssue[];
}
function trimIssueMessage(message: string): string {
const trimmed = message.trim();
return trimmed.length > 0 ? trimmed : "Invalid keybindings configuration.";
}
function malformedConfigIssue(detail: string): ServerConfigIssue {
return {
kind: "keybindings.malformed-config",
message: trimIssueMessage(detail),
};
}
function invalidEntryIssue(index: number, detail: string): ServerConfigIssue {
return {
kind: "keybindings.invalid-entry",
index,
message: trimIssueMessage(detail),
};
}
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 <= MAX_KEYBINDINGS_COUNT) {
return merged;
}
// Keep the latest rules when the config exceeds max size; later rules have higher precedence.
return merged.slice(-MAX_KEYBINDINGS_COUNT);
}
/**
* KeybindingsShape - Service API for keybinding configuration operations.
*/
export interface KeybindingsShape {
/**
* Ensure the on-disk keybindings file exists and includes all default
* commands so newly-added defaults are backfilled on startup.
*/
readonly syncDefaultKeybindingsOnStartup: Effect.Effect<void, KeybindingsConfigError>;
/**
* Load runtime keybindings state along with non-fatal configuration issues.
*/
readonly loadConfigState: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>;
/**
* Stream keybindings config change events.
*/
readonly changes: Stream.Stream<KeybindingsChangeEvent>;
/**
* Upsert a keybinding rule and persist the resulting configuration.
*
* Writes config atomically and enforces the max rule count by truncating
* oldest entries when needed.
*/
readonly upsertKeybindingRule: (
rule: KeybindingRule,
) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;
}
/**
* Keybindings - Service tag for keybinding configuration operations.
*/
export class Keybindings extends ServiceMap.Service<Keybindings, KeybindingsShape>()(
"t3/keybindings",
) {}
const makeKeybindings = Effect.gen(function* () {
const { keybindingsConfigPath } = yield* ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const upsertSemaphore = yield* Semaphore.make(1);
const resolvedConfigCacheKey = "resolved" as const;
const changesPubSub = yield* PubSub.unbounded<KeybindingsChangeEvent>();
const emitChange = (issues: readonly ServerConfigIssue[]) =>
PubSub.publish(changesPubSub, { issues }).pipe(Effect.asVoid);
const readConfigExists = fs.exists(keybindingsConfigPath).pipe(
Effect.mapError(
(cause) =>
new KeybindingsConfigError({
configPath: keybindingsConfigPath,
detail: "failed to access keybindings config",
cause,
}),
),
);
const readRawConfig = fs.readFileString(keybindingsConfigPath).pipe(
Effect.mapError(
(cause) =>
new KeybindingsConfigError({
configPath: keybindingsConfigPath,
detail: "failed to read keybindings config",
cause,
}),
),
);
const loadWritableCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
readonly KeybindingRule[],
KeybindingsConfigError
> {
if (!(yield* readConfigExists)) {
return [];
}
const rawConfig = yield* readRawConfig.pipe(
Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)),
Effect.mapError(
(cause) =>
new KeybindingsConfigError({
configPath: keybindingsConfigPath,
detail: "expected JSON array",
cause,
}),
),
);
return yield* Effect.forEach(rawConfig, (entry) =>
Effect.gen(function* () {
const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry);
if (decodedRule._tag === "Failure") {
yield* Effect.logWarning("ignoring invalid keybinding entry", {
path: keybindingsConfigPath,
entry,
error: Cause.pretty(decodedRule.cause),
});
return null;
}
const resolved = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value);
if (resolved._tag === "Failure") {
yield* Effect.logWarning("ignoring invalid keybinding entry", {
path: keybindingsConfigPath,
entry,
error: Cause.pretty(resolved.cause),
});
return null;
}
return decodedRule.value;
}),
).pipe(Effect.map(Array.filter(Predicate.isNotNull)));
});
const loadRuntimeCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
{
readonly keybindings: readonly KeybindingRule[];
readonly issues: readonly ServerConfigIssue[];
},
KeybindingsConfigError
> {
if (!(yield* readConfigExists)) {
return { keybindings: [], issues: [] };
}
const rawConfig = yield* readRawConfig;
const decodedEntries = Schema.decodeUnknownExit(RawKeybindingsEntries)(rawConfig);
if (decodedEntries._tag === "Failure") {
const detail = `expected JSON array (${Cause.pretty(decodedEntries.cause)})`;
return {
keybindings: [],
issues: [malformedConfigIssue(detail)],
};
}
const keybindings: KeybindingRule[] = [];
const issues: ServerConfigIssue[] = [];
for (const [index, entry] of decodedEntries.value.entries()) {
const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry);
if (decodedRule._tag === "Failure") {
const detail = Cause.pretty(decodedRule.cause);
issues.push(invalidEntryIssue(index, detail));
yield* Effect.logWarning("ignoring invalid keybinding entry", {
path: keybindingsConfigPath,
index,
entry,
error: detail,
});
continue;
}
const resolvedRule = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value);
if (resolvedRule._tag === "Failure") {
const detail = Cause.pretty(resolvedRule.cause);
issues.push(invalidEntryIssue(index, detail));
yield* Effect.logWarning("ignoring invalid keybinding entry", {
path: keybindingsConfigPath,
index,
entry,
error: detail,
});
continue;
}
keybindings.push(decodedRule.value);
}
return { keybindings, issues };
});
const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe(
Effect.map((encoded) => `${encoded}\n`),
Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),
Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),
Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)),
Effect.mapError(
(cause) =>
new KeybindingsConfigError({
configPath: keybindingsConfigPath,
detail: "failed to write keybindings config",
cause,
}),
),
);
};
const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe(
Effect.map(({ keybindings, issues }) => ({
keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)),
issues,
})),
);
const resolvedConfigCache = yield* Cache.make<
typeof resolvedConfigCacheKey,
KeybindingsConfigState,
KeybindingsConfigError
>({
capacity: 1,
lookup: () => loadConfigStateFromDisk,
});
const loadConfigStateFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey);
const revalidateAndEmit = upsertSemaphore.withPermits(1)(
Effect.gen(function* () {
yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
const configState = yield* loadConfigStateFromCacheOrDisk;
yield* emitChange(configState.issues);
}),
);
const keybindingsConfigDir = path.dirname(keybindingsConfigPath);
const keybindingsConfigFile = path.basename(keybindingsConfigPath);
const keybindingsConfigPathResolved = path.resolve(keybindingsConfigPath);
yield* fs
.makeDirectory(keybindingsConfigDir, { recursive: true })
.pipe(Effect.orElseSucceed(() => undefined));
yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => {
const isTargetConfigEvent =
event.path === keybindingsConfigFile ||
event.path === keybindingsConfigPath ||
path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved;
if (!isTargetConfigEvent) {
return Effect.void;
}
return revalidateAndEmit.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to revalidate keybindings config after file update", {
path: keybindingsConfigPath,
detail: error.detail,
cause: error.cause,
}),
),
);
}).pipe(
Effect.catch((cause) =>
Effect.logWarning("keybindings config watcher stopped unexpectedly", {
path: keybindingsConfigPath,
cause,
}),
),
Effect.forkScoped,
);
const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(
Effect.gen(function* () {
const configExists = yield* readConfigExists;
if (!configExists) {
yield* writeConfigAtomically(DEFAULT_KEYBINDINGS);
yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
return;
}
const runtimeConfig = yield* loadRuntimeCustomKeybindingsConfig();
if (runtimeConfig.issues.length > 0) {
yield* Effect.logWarning(
"skipping startup keybindings default sync because config has issues",
{
path: keybindingsConfigPath,
issues: runtimeConfig.issues,
},
);
yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
return;
}
const customConfig = runtimeConfig.keybindings;
const existingCommands = new Set(customConfig.map((entry) => entry.command));
const missingDefaults: KeybindingRule[] = [];
const shortcutConflictWarnings: Array<{
defaultCommand: KeybindingRule["command"];
conflictingCommand: KeybindingRule["command"];
key: string;
when: string | null;
}> = [];
for (const defaultRule of DEFAULT_KEYBINDINGS) {
if (existingCommands.has(defaultRule.command)) {
continue;
}
const conflictingEntry = customConfig.find((entry) =>
hasSameShortcutContext(entry, defaultRule),
);
if (conflictingEntry) {
shortcutConflictWarnings.push({
defaultCommand: defaultRule.command,
conflictingCommand: conflictingEntry.command,
key: defaultRule.key,
when: defaultRule.when ?? null,
});
continue;
}
missingDefaults.push(defaultRule);
}
for (const conflict of shortcutConflictWarnings) {
yield* Effect.logWarning("skipping default keybinding due to shortcut conflict", {
path: keybindingsConfigPath,
defaultCommand: conflict.defaultCommand,
conflictingCommand: conflict.conflictingCommand,
key: conflict.key,
when: conflict.when,
reason: "shortcut context already used by existing rule",
});
}
if (missingDefaults.length === 0) {
yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
return;
}
const matchingDefaults = DEFAULT_KEYBINDINGS.filter((defaultRule) =>
customConfig.some((entry) => isSameKeybindingRule(entry, defaultRule)),
).map((rule) => rule.command);
if (matchingDefaults.length > 0) {
yield* Effect.logWarning("default keybinding rule already defined in user config", {
path: keybindingsConfigPath,
commands: matchingDefaults,
});
}
const nextConfig = [...customConfig, ...missingDefaults];
const cappedConfig =
nextConfig.length > MAX_KEYBINDINGS_COUNT
? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
: nextConfig;
if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
yield* Effect.logWarning("truncating keybindings config to max entries", {
path: keybindingsConfigPath,
maxEntries: MAX_KEYBINDINGS_COUNT,
});
}
yield* writeConfigAtomically(cappedConfig);
yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
}),
);
return {
syncDefaultKeybindingsOnStartup,
loadConfigState: loadConfigStateFromCacheOrDisk,
changes: Stream.fromPubSub(changesPubSub),
upsertKeybindingRule: (rule) =>
upsertSemaphore.withPermits(1)(
Effect.gen(function* () {
const customConfig = yield* loadWritableCustomKeybindingsConfig();
const nextConfig = [
...customConfig.filter((entry) => entry.command !== rule.command),
rule,
];
const cappedConfig =
nextConfig.length > MAX_KEYBINDINGS_COUNT
? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
: nextConfig;
if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
yield* Effect.logWarning("truncating keybindings config to max entries", {
path: keybindingsConfigPath,
maxEntries: MAX_KEYBINDINGS_COUNT,
});
}
yield* writeConfigAtomically(cappedConfig);
const nextResolved = mergeWithDefaultKeybindings(
compileResolvedKeybindingsConfig(cappedConfig),
);
yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
keybindings: nextResolved,
issues: [],
});
yield* emitChange([]);
return nextResolved;
}),
),
} satisfies KeybindingsShape;
});
export const KeybindingsLive = Layer.effect(Keybindings, makeKeybindings);