473 lines
14 KiB
TypeScript
473 lines
14 KiB
TypeScript
/**
|
|
* User keybinding configuration loader with hot-reload support.
|
|
*
|
|
* Loads keybindings from ~/.claude/keybindings.json and watches
|
|
* for changes to reload them automatically.
|
|
*
|
|
* NOTE: User keybinding customization is currently only available for
|
|
* Anthropic employees (USER_TYPE === 'ant'). External users always
|
|
* use the default bindings.
|
|
*/
|
|
|
|
import chokidar, { type FSWatcher } from 'chokidar'
|
|
import { readFileSync } from 'fs'
|
|
import { readFile, stat } from 'fs/promises'
|
|
import { dirname, join } from 'path'
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
|
import { logEvent } from '../services/analytics/index.js'
|
|
import { registerCleanup } from '../utils/cleanupRegistry.js'
|
|
import { logForDebugging } from '../utils/debug.js'
|
|
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
|
import { errorMessage, isENOENT } from '../utils/errors.js'
|
|
import { createSignal } from '../utils/signal.js'
|
|
import { jsonParse } from '../utils/slowOperations.js'
|
|
import { DEFAULT_BINDINGS } from './defaultBindings.js'
|
|
import { parseBindings } from './parser.js'
|
|
import type { KeybindingBlock, ParsedBinding } from './types.js'
|
|
import {
|
|
checkDuplicateKeysInJson,
|
|
type KeybindingWarning,
|
|
validateBindings,
|
|
} from './validate.js'
|
|
|
|
/**
|
|
* Check if keybinding customization is enabled.
|
|
*
|
|
* Returns true if the tengu_keybinding_customization_release GrowthBook gate is enabled.
|
|
*
|
|
* This function is exported so other parts of the codebase (e.g., /doctor)
|
|
* can check the same condition consistently.
|
|
*/
|
|
export function isKeybindingCustomizationEnabled(): boolean {
|
|
return getFeatureValue_CACHED_MAY_BE_STALE(
|
|
'tengu_keybinding_customization_release',
|
|
false,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Time in milliseconds to wait for file writes to stabilize.
|
|
*/
|
|
const FILE_STABILITY_THRESHOLD_MS = 500
|
|
|
|
/**
|
|
* Polling interval for checking file stability.
|
|
*/
|
|
const FILE_STABILITY_POLL_INTERVAL_MS = 200
|
|
|
|
/**
|
|
* Result of loading keybindings, including any validation warnings.
|
|
*/
|
|
export type KeybindingsLoadResult = {
|
|
bindings: ParsedBinding[]
|
|
warnings: KeybindingWarning[]
|
|
}
|
|
|
|
let watcher: FSWatcher | null = null
|
|
let initialized = false
|
|
let disposed = false
|
|
let cachedBindings: ParsedBinding[] | null = null
|
|
let cachedWarnings: KeybindingWarning[] = []
|
|
const keybindingsChanged = createSignal<[result: KeybindingsLoadResult]>()
|
|
|
|
/**
|
|
* Tracks the date (YYYY-MM-DD) when we last logged a custom keybindings load event.
|
|
* Used to ensure we fire the event at most once per day.
|
|
*/
|
|
let lastCustomBindingsLogDate: string | null = null
|
|
|
|
/**
|
|
* Log a telemetry event when custom keybindings are loaded, at most once per day.
|
|
* This lets us estimate the percentage of users who customize their keybindings.
|
|
*/
|
|
function logCustomBindingsLoadedOncePerDay(userBindingCount: number): void {
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
if (lastCustomBindingsLogDate === today) return
|
|
lastCustomBindingsLogDate = today
|
|
logEvent('tengu_custom_keybindings_loaded', {
|
|
user_binding_count: userBindingCount,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Type guard to check if an object is a valid KeybindingBlock.
|
|
*/
|
|
function isKeybindingBlock(obj: unknown): obj is KeybindingBlock {
|
|
if (typeof obj !== 'object' || obj === null) return false
|
|
const b = obj as Record<string, unknown>
|
|
return (
|
|
typeof b.context === 'string' &&
|
|
typeof b.bindings === 'object' &&
|
|
b.bindings !== null
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Type guard to check if an array contains only valid KeybindingBlocks.
|
|
*/
|
|
function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] {
|
|
return Array.isArray(arr) && arr.every(isKeybindingBlock)
|
|
}
|
|
|
|
/**
|
|
* Get the path to the user keybindings file.
|
|
*/
|
|
export function getKeybindingsPath(): string {
|
|
return join(getClaudeConfigHomeDir(), 'keybindings.json')
|
|
}
|
|
|
|
/**
|
|
* Parse default bindings (cached for performance).
|
|
*/
|
|
function getDefaultParsedBindings(): ParsedBinding[] {
|
|
return parseBindings(DEFAULT_BINDINGS)
|
|
}
|
|
|
|
/**
|
|
* Load and parse keybindings from user config file.
|
|
* Returns merged default + user bindings along with validation warnings.
|
|
*
|
|
* For external users, always returns default bindings only.
|
|
* User customization is currently gated to Anthropic employees.
|
|
*/
|
|
export async function loadKeybindings(): Promise<KeybindingsLoadResult> {
|
|
const defaultBindings = getDefaultParsedBindings()
|
|
|
|
// Skip user config loading for external users
|
|
if (!isKeybindingCustomizationEnabled()) {
|
|
return { bindings: defaultBindings, warnings: [] }
|
|
}
|
|
|
|
const userPath = getKeybindingsPath()
|
|
|
|
try {
|
|
const content = await readFile(userPath, 'utf-8')
|
|
const parsed: unknown = jsonParse(content)
|
|
|
|
// Extract bindings array from object wrapper format: { "bindings": [...] }
|
|
let userBlocks: unknown
|
|
if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) {
|
|
userBlocks = (parsed as { bindings: unknown }).bindings
|
|
} else {
|
|
// Invalid format - missing bindings property
|
|
const errorMessage = 'keybindings.json must have a "bindings" array'
|
|
const suggestion = 'Use format: { "bindings": [ ... ] }'
|
|
logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`)
|
|
return {
|
|
bindings: defaultBindings,
|
|
warnings: [
|
|
{
|
|
type: 'parse_error',
|
|
severity: 'error',
|
|
message: errorMessage,
|
|
suggestion,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
// Validate structure - bindings must be an array of valid keybinding blocks
|
|
if (!isKeybindingBlockArray(userBlocks)) {
|
|
const errorMessage = !Array.isArray(userBlocks)
|
|
? '"bindings" must be an array'
|
|
: 'keybindings.json contains invalid block structure'
|
|
const suggestion = !Array.isArray(userBlocks)
|
|
? 'Set "bindings" to an array of keybinding blocks'
|
|
: 'Each block must have "context" (string) and "bindings" (object)'
|
|
logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`)
|
|
return {
|
|
bindings: defaultBindings,
|
|
warnings: [
|
|
{
|
|
type: 'parse_error',
|
|
severity: 'error',
|
|
message: errorMessage,
|
|
suggestion,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
const userParsed = parseBindings(userBlocks)
|
|
logForDebugging(
|
|
`[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`,
|
|
)
|
|
|
|
// User bindings come after defaults, so they override
|
|
const mergedBindings = [...defaultBindings, ...userParsed]
|
|
|
|
logCustomBindingsLoadedOncePerDay(userParsed.length)
|
|
|
|
// Run validation on user config
|
|
// First check for duplicate keys in raw JSON (JSON.parse silently drops earlier values)
|
|
const duplicateKeyWarnings = checkDuplicateKeysInJson(content)
|
|
const warnings = [
|
|
...duplicateKeyWarnings,
|
|
...validateBindings(userBlocks, mergedBindings),
|
|
]
|
|
|
|
if (warnings.length > 0) {
|
|
logForDebugging(
|
|
`[keybindings] Found ${warnings.length} validation issue(s)`,
|
|
)
|
|
}
|
|
|
|
return { bindings: mergedBindings, warnings }
|
|
} catch (error) {
|
|
// File doesn't exist - use defaults (user can run /keybindings to create)
|
|
if (isENOENT(error)) {
|
|
return { bindings: defaultBindings, warnings: [] }
|
|
}
|
|
|
|
// Other error - log and return defaults with warning
|
|
logForDebugging(
|
|
`[keybindings] Error loading ${userPath}: ${errorMessage(error)}`,
|
|
)
|
|
return {
|
|
bindings: defaultBindings,
|
|
warnings: [
|
|
{
|
|
type: 'parse_error',
|
|
severity: 'error',
|
|
message: `Failed to parse keybindings.json: ${errorMessage(error)}`,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load keybindings synchronously (for initial render).
|
|
* Uses cached value if available.
|
|
*/
|
|
export function loadKeybindingsSync(): ParsedBinding[] {
|
|
if (cachedBindings) {
|
|
return cachedBindings
|
|
}
|
|
|
|
const result = loadKeybindingsSyncWithWarnings()
|
|
return result.bindings
|
|
}
|
|
|
|
/**
|
|
* Load keybindings synchronously with validation warnings.
|
|
* Uses cached values if available.
|
|
*
|
|
* For external users, always returns default bindings only.
|
|
* User customization is currently gated to Anthropic employees.
|
|
*/
|
|
export function loadKeybindingsSyncWithWarnings(): KeybindingsLoadResult {
|
|
if (cachedBindings) {
|
|
return { bindings: cachedBindings, warnings: cachedWarnings }
|
|
}
|
|
|
|
const defaultBindings = getDefaultParsedBindings()
|
|
|
|
// Skip user config loading for external users
|
|
if (!isKeybindingCustomizationEnabled()) {
|
|
cachedBindings = defaultBindings
|
|
cachedWarnings = []
|
|
return { bindings: cachedBindings, warnings: cachedWarnings }
|
|
}
|
|
|
|
const userPath = getKeybindingsPath()
|
|
|
|
try {
|
|
// sync IO: called from sync context (React useState initializer)
|
|
const content = readFileSync(userPath, 'utf-8')
|
|
const parsed: unknown = jsonParse(content)
|
|
|
|
// Extract bindings array from object wrapper format: { "bindings": [...] }
|
|
let userBlocks: unknown
|
|
if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) {
|
|
userBlocks = (parsed as { bindings: unknown }).bindings
|
|
} else {
|
|
// Invalid format - missing bindings property
|
|
cachedBindings = defaultBindings
|
|
cachedWarnings = [
|
|
{
|
|
type: 'parse_error',
|
|
severity: 'error',
|
|
message: 'keybindings.json must have a "bindings" array',
|
|
suggestion: 'Use format: { "bindings": [ ... ] }',
|
|
},
|
|
]
|
|
return { bindings: cachedBindings, warnings: cachedWarnings }
|
|
}
|
|
|
|
// Validate structure - bindings must be an array of valid keybinding blocks
|
|
if (!isKeybindingBlockArray(userBlocks)) {
|
|
const errorMessage = !Array.isArray(userBlocks)
|
|
? '"bindings" must be an array'
|
|
: 'keybindings.json contains invalid block structure'
|
|
const suggestion = !Array.isArray(userBlocks)
|
|
? 'Set "bindings" to an array of keybinding blocks'
|
|
: 'Each block must have "context" (string) and "bindings" (object)'
|
|
cachedBindings = defaultBindings
|
|
cachedWarnings = [
|
|
{
|
|
type: 'parse_error',
|
|
severity: 'error',
|
|
message: errorMessage,
|
|
suggestion,
|
|
},
|
|
]
|
|
return { bindings: cachedBindings, warnings: cachedWarnings }
|
|
}
|
|
|
|
const userParsed = parseBindings(userBlocks)
|
|
logForDebugging(
|
|
`[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`,
|
|
)
|
|
cachedBindings = [...defaultBindings, ...userParsed]
|
|
|
|
logCustomBindingsLoadedOncePerDay(userParsed.length)
|
|
|
|
// Run validation - check for duplicate keys in raw JSON first
|
|
const duplicateKeyWarnings = checkDuplicateKeysInJson(content)
|
|
cachedWarnings = [
|
|
...duplicateKeyWarnings,
|
|
...validateBindings(userBlocks, cachedBindings),
|
|
]
|
|
if (cachedWarnings.length > 0) {
|
|
logForDebugging(
|
|
`[keybindings] Found ${cachedWarnings.length} validation issue(s)`,
|
|
)
|
|
}
|
|
|
|
return { bindings: cachedBindings, warnings: cachedWarnings }
|
|
} catch {
|
|
// File doesn't exist or error - use defaults (user can run /keybindings to create)
|
|
cachedBindings = defaultBindings
|
|
cachedWarnings = []
|
|
return { bindings: cachedBindings, warnings: cachedWarnings }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize file watching for keybindings.json.
|
|
* Call this once when the app starts.
|
|
*
|
|
* For external users, this is a no-op since user customization is disabled.
|
|
*/
|
|
export async function initializeKeybindingWatcher(): Promise<void> {
|
|
if (initialized || disposed) return
|
|
|
|
// Skip file watching for external users
|
|
if (!isKeybindingCustomizationEnabled()) {
|
|
logForDebugging(
|
|
'[keybindings] Skipping file watcher - user customization disabled',
|
|
)
|
|
return
|
|
}
|
|
|
|
const userPath = getKeybindingsPath()
|
|
const watchDir = dirname(userPath)
|
|
|
|
// Only watch if parent directory exists
|
|
try {
|
|
const stats = await stat(watchDir)
|
|
if (!stats.isDirectory()) {
|
|
logForDebugging(
|
|
`[keybindings] Not watching: ${watchDir} is not a directory`,
|
|
)
|
|
return
|
|
}
|
|
} catch {
|
|
logForDebugging(`[keybindings] Not watching: ${watchDir} does not exist`)
|
|
return
|
|
}
|
|
|
|
// Set initialized only after we've confirmed we can watch
|
|
initialized = true
|
|
|
|
logForDebugging(`[keybindings] Watching for changes to ${userPath}`)
|
|
|
|
watcher = chokidar.watch(userPath, {
|
|
persistent: true,
|
|
ignoreInitial: true,
|
|
awaitWriteFinish: {
|
|
stabilityThreshold: FILE_STABILITY_THRESHOLD_MS,
|
|
pollInterval: FILE_STABILITY_POLL_INTERVAL_MS,
|
|
},
|
|
ignorePermissionErrors: true,
|
|
usePolling: false,
|
|
atomic: true,
|
|
})
|
|
|
|
watcher.on('add', handleChange)
|
|
watcher.on('change', handleChange)
|
|
watcher.on('unlink', handleDelete)
|
|
|
|
// Register cleanup
|
|
registerCleanup(async () => disposeKeybindingWatcher())
|
|
}
|
|
|
|
/**
|
|
* Clean up the file watcher.
|
|
*/
|
|
export function disposeKeybindingWatcher(): void {
|
|
disposed = true
|
|
if (watcher) {
|
|
void watcher.close()
|
|
watcher = null
|
|
}
|
|
keybindingsChanged.clear()
|
|
}
|
|
|
|
/**
|
|
* Subscribe to keybinding changes.
|
|
* The listener receives the new parsed bindings when the file changes.
|
|
*/
|
|
export const subscribeToKeybindingChanges = keybindingsChanged.subscribe
|
|
|
|
async function handleChange(path: string): Promise<void> {
|
|
logForDebugging(`[keybindings] Detected change to ${path}`)
|
|
|
|
try {
|
|
const result = await loadKeybindings()
|
|
cachedBindings = result.bindings
|
|
cachedWarnings = result.warnings
|
|
|
|
// Notify all listeners with the full result
|
|
keybindingsChanged.emit(result)
|
|
} catch (error) {
|
|
logForDebugging(`[keybindings] Error reloading: ${errorMessage(error)}`)
|
|
}
|
|
}
|
|
|
|
function handleDelete(path: string): void {
|
|
logForDebugging(`[keybindings] Detected deletion of ${path}`)
|
|
|
|
// Reset to defaults when file is deleted
|
|
const defaultBindings = getDefaultParsedBindings()
|
|
cachedBindings = defaultBindings
|
|
cachedWarnings = []
|
|
|
|
keybindingsChanged.emit({ bindings: defaultBindings, warnings: [] })
|
|
}
|
|
|
|
/**
|
|
* Get the cached keybinding warnings.
|
|
* Returns empty array if no warnings or bindings haven't been loaded yet.
|
|
*/
|
|
export function getCachedKeybindingWarnings(): KeybindingWarning[] {
|
|
return cachedWarnings
|
|
}
|
|
|
|
/**
|
|
* Reset internal state for testing.
|
|
*/
|
|
export function resetKeybindingLoaderForTesting(): void {
|
|
initialized = false
|
|
disposed = false
|
|
cachedBindings = null
|
|
cachedWarnings = []
|
|
lastCustomBindingsLogDate = null
|
|
if (watcher) {
|
|
void watcher.close()
|
|
watcher = null
|
|
}
|
|
keybindingsChanged.clear()
|
|
}
|