Download src/keybindings/loadUserBindings.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 14.6 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/keybindings/loadUserBindings.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/keybindings/loadUserBindings.ts
-
curl -L -o loadUserBindings.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/keybindings/loadUserBindings.ts
14.6 kB
| /** | |
| * 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() | |
| } | |