init: add source code from src.zip
This commit is contained in:
243
src/keybindings/KeybindingContext.tsx
Normal file
243
src/keybindings/KeybindingContext.tsx
Normal file
File diff suppressed because one or more lines are too long
308
src/keybindings/KeybindingProviderSetup.tsx
Normal file
308
src/keybindings/KeybindingProviderSetup.tsx
Normal file
File diff suppressed because one or more lines are too long
340
src/keybindings/defaultBindings.ts
Normal file
340
src/keybindings/defaultBindings.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { satisfies } from 'src/utils/semver.js'
|
||||
import { isRunningWithBun } from '../utils/bundledMode.js'
|
||||
import { getPlatform } from '../utils/platform.js'
|
||||
import type { KeybindingBlock } from './types.js'
|
||||
|
||||
/**
|
||||
* Default keybindings that match current Claude Code behavior.
|
||||
* These are loaded first, then user keybindings.json overrides them.
|
||||
*/
|
||||
|
||||
// Platform-specific image paste shortcut:
|
||||
// - Windows: alt+v (ctrl+v is system paste)
|
||||
// - Other platforms: ctrl+v
|
||||
const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v'
|
||||
|
||||
// Modifier-only chords (like shift+tab) may fail on Windows Terminal without VT mode
|
||||
// See: https://github.com/microsoft/terminal/issues/879#issuecomment-618801651
|
||||
// Node enabled VT mode in 24.2.0 / 22.17.0: https://github.com/nodejs/node/pull/58358
|
||||
// Bun enabled VT mode in 1.2.23: https://github.com/oven-sh/bun/pull/21161
|
||||
const SUPPORTS_TERMINAL_VT_MODE =
|
||||
getPlatform() !== 'windows' ||
|
||||
(isRunningWithBun()
|
||||
? satisfies(process.versions.bun, '>=1.2.23')
|
||||
: satisfies(process.versions.node, '>=22.17.0 <23.0.0 || >=24.2.0'))
|
||||
|
||||
// Platform-specific mode cycle shortcut:
|
||||
// - Windows without VT mode: meta+m (shift+tab doesn't work reliably)
|
||||
// - Other platforms: shift+tab
|
||||
const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m'
|
||||
|
||||
export const DEFAULT_BINDINGS: KeybindingBlock[] = [
|
||||
{
|
||||
context: 'Global',
|
||||
bindings: {
|
||||
// ctrl+c and ctrl+d use special time-based double-press handling.
|
||||
// They ARE defined here so the resolver can find them, but they
|
||||
// CANNOT be rebound by users - validation in reservedShortcuts.ts
|
||||
// will show an error if users try to override these keys.
|
||||
'ctrl+c': 'app:interrupt',
|
||||
'ctrl+d': 'app:exit',
|
||||
'ctrl+l': 'app:redraw',
|
||||
'ctrl+t': 'app:toggleTodos',
|
||||
'ctrl+o': 'app:toggleTranscript',
|
||||
...(feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? { 'ctrl+shift+b': 'app:toggleBrief' as const }
|
||||
: {}),
|
||||
'ctrl+shift+o': 'app:toggleTeammatePreview',
|
||||
'ctrl+r': 'history:search',
|
||||
// File navigation. cmd+ bindings only fire on kitty-protocol terminals;
|
||||
// ctrl+shift is the portable fallback.
|
||||
...(feature('QUICK_SEARCH')
|
||||
? {
|
||||
'ctrl+shift+f': 'app:globalSearch' as const,
|
||||
'cmd+shift+f': 'app:globalSearch' as const,
|
||||
'ctrl+shift+p': 'app:quickOpen' as const,
|
||||
'cmd+shift+p': 'app:quickOpen' as const,
|
||||
}
|
||||
: {}),
|
||||
...(feature('TERMINAL_PANEL') ? { 'meta+j': 'app:toggleTerminal' } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Chat',
|
||||
bindings: {
|
||||
escape: 'chat:cancel',
|
||||
// ctrl+x chord prefix avoids shadowing readline editing keys (ctrl+a/b/e/f/...).
|
||||
'ctrl+x ctrl+k': 'chat:killAgents',
|
||||
[MODE_CYCLE_KEY]: 'chat:cycleMode',
|
||||
'meta+p': 'chat:modelPicker',
|
||||
'meta+o': 'chat:fastMode',
|
||||
'meta+t': 'chat:thinkingToggle',
|
||||
enter: 'chat:submit',
|
||||
up: 'history:previous',
|
||||
down: 'history:next',
|
||||
// Editing shortcuts (defined here, migration in progress)
|
||||
// Undo has two bindings to support different terminal behaviors:
|
||||
// - ctrl+_ for legacy terminals (send \x1f control char)
|
||||
// - ctrl+shift+- for Kitty protocol (sends physical key with modifiers)
|
||||
'ctrl+_': 'chat:undo',
|
||||
'ctrl+shift+-': 'chat:undo',
|
||||
// ctrl+x ctrl+e is the readline-native edit-and-execute-command binding.
|
||||
'ctrl+x ctrl+e': 'chat:externalEditor',
|
||||
'ctrl+g': 'chat:externalEditor',
|
||||
'ctrl+s': 'chat:stash',
|
||||
// Image paste shortcut (platform-specific key defined above)
|
||||
[IMAGE_PASTE_KEY]: 'chat:imagePaste',
|
||||
...(feature('MESSAGE_ACTIONS')
|
||||
? { 'shift+up': 'chat:messageActions' as const }
|
||||
: {}),
|
||||
// Voice activation (hold-to-talk). Registered so getShortcutDisplay
|
||||
// finds it without hitting the fallback analytics log. To rebind,
|
||||
// add a voice:pushToTalk entry (last wins); to disable, use /voice
|
||||
// — null-unbinding space hits a pre-existing useKeybinding.ts trap
|
||||
// where 'unbound' swallows the event (space dead for typing).
|
||||
...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Autocomplete',
|
||||
bindings: {
|
||||
tab: 'autocomplete:accept',
|
||||
escape: 'autocomplete:dismiss',
|
||||
up: 'autocomplete:previous',
|
||||
down: 'autocomplete:next',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Settings',
|
||||
bindings: {
|
||||
// Settings menu uses escape only (not 'n') to dismiss
|
||||
escape: 'confirm:no',
|
||||
// Config panel list navigation (reuses Select actions)
|
||||
up: 'select:previous',
|
||||
down: 'select:next',
|
||||
k: 'select:previous',
|
||||
j: 'select:next',
|
||||
'ctrl+p': 'select:previous',
|
||||
'ctrl+n': 'select:next',
|
||||
// Toggle/activate the selected setting (space only — enter saves & closes)
|
||||
space: 'select:accept',
|
||||
// Save and close the config panel
|
||||
enter: 'settings:close',
|
||||
// Enter search mode
|
||||
'/': 'settings:search',
|
||||
// Retry loading usage data (only active on error)
|
||||
r: 'settings:retry',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Confirmation',
|
||||
bindings: {
|
||||
y: 'confirm:yes',
|
||||
n: 'confirm:no',
|
||||
enter: 'confirm:yes',
|
||||
escape: 'confirm:no',
|
||||
// Navigation for dialogs with lists
|
||||
up: 'confirm:previous',
|
||||
down: 'confirm:next',
|
||||
tab: 'confirm:nextField',
|
||||
space: 'confirm:toggle',
|
||||
// Cycle modes (used in file permission dialogs and teams dialog)
|
||||
'shift+tab': 'confirm:cycleMode',
|
||||
// Toggle permission explanation in permission dialogs
|
||||
'ctrl+e': 'confirm:toggleExplanation',
|
||||
// Toggle permission debug info
|
||||
'ctrl+d': 'permission:toggleDebug',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Tabs',
|
||||
bindings: {
|
||||
// Tab cycling navigation
|
||||
tab: 'tabs:next',
|
||||
'shift+tab': 'tabs:previous',
|
||||
right: 'tabs:next',
|
||||
left: 'tabs:previous',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Transcript',
|
||||
bindings: {
|
||||
'ctrl+e': 'transcript:toggleShowAll',
|
||||
'ctrl+c': 'transcript:exit',
|
||||
escape: 'transcript:exit',
|
||||
// q — pager convention (less, tmux copy-mode). Transcript is a modal
|
||||
// reading view with no prompt, so q-as-literal-char has no owner.
|
||||
q: 'transcript:exit',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'HistorySearch',
|
||||
bindings: {
|
||||
'ctrl+r': 'historySearch:next',
|
||||
escape: 'historySearch:accept',
|
||||
tab: 'historySearch:accept',
|
||||
'ctrl+c': 'historySearch:cancel',
|
||||
enter: 'historySearch:execute',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Task',
|
||||
bindings: {
|
||||
// Background running foreground tasks (bash commands, agents)
|
||||
// In tmux, users must press ctrl+b twice (tmux prefix escape)
|
||||
'ctrl+b': 'task:background',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'ThemePicker',
|
||||
bindings: {
|
||||
'ctrl+t': 'theme:toggleSyntaxHighlighting',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Scroll',
|
||||
bindings: {
|
||||
pageup: 'scroll:pageUp',
|
||||
pagedown: 'scroll:pageDown',
|
||||
wheelup: 'scroll:lineUp',
|
||||
wheeldown: 'scroll:lineDown',
|
||||
'ctrl+home': 'scroll:top',
|
||||
'ctrl+end': 'scroll:bottom',
|
||||
// Selection copy. ctrl+shift+c is standard terminal copy.
|
||||
// cmd+c only fires on terminals using the kitty keyboard
|
||||
// protocol (kitty/WezTerm/ghostty/iTerm2) where the super
|
||||
// modifier actually reaches the pty — inert elsewhere.
|
||||
// Esc-to-clear and contextual ctrl+c are handled via raw
|
||||
// useInput so they can conditionally propagate.
|
||||
'ctrl+shift+c': 'selection:copy',
|
||||
'cmd+c': 'selection:copy',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Help',
|
||||
bindings: {
|
||||
escape: 'help:dismiss',
|
||||
},
|
||||
},
|
||||
// Attachment navigation (select dialog image attachments)
|
||||
{
|
||||
context: 'Attachments',
|
||||
bindings: {
|
||||
right: 'attachments:next',
|
||||
left: 'attachments:previous',
|
||||
backspace: 'attachments:remove',
|
||||
delete: 'attachments:remove',
|
||||
down: 'attachments:exit',
|
||||
escape: 'attachments:exit',
|
||||
},
|
||||
},
|
||||
// Footer indicator navigation (tasks, teams, diff, loop)
|
||||
{
|
||||
context: 'Footer',
|
||||
bindings: {
|
||||
up: 'footer:up',
|
||||
'ctrl+p': 'footer:up',
|
||||
down: 'footer:down',
|
||||
'ctrl+n': 'footer:down',
|
||||
right: 'footer:next',
|
||||
left: 'footer:previous',
|
||||
enter: 'footer:openSelected',
|
||||
escape: 'footer:clearSelection',
|
||||
},
|
||||
},
|
||||
// Message selector (rewind dialog) navigation
|
||||
{
|
||||
context: 'MessageSelector',
|
||||
bindings: {
|
||||
up: 'messageSelector:up',
|
||||
down: 'messageSelector:down',
|
||||
k: 'messageSelector:up',
|
||||
j: 'messageSelector:down',
|
||||
'ctrl+p': 'messageSelector:up',
|
||||
'ctrl+n': 'messageSelector:down',
|
||||
'ctrl+up': 'messageSelector:top',
|
||||
'shift+up': 'messageSelector:top',
|
||||
'meta+up': 'messageSelector:top',
|
||||
'shift+k': 'messageSelector:top',
|
||||
'ctrl+down': 'messageSelector:bottom',
|
||||
'shift+down': 'messageSelector:bottom',
|
||||
'meta+down': 'messageSelector:bottom',
|
||||
'shift+j': 'messageSelector:bottom',
|
||||
enter: 'messageSelector:select',
|
||||
},
|
||||
},
|
||||
// PromptInput unmounts while cursor active — no key conflict.
|
||||
...(feature('MESSAGE_ACTIONS')
|
||||
? [
|
||||
{
|
||||
context: 'MessageActions' as const,
|
||||
bindings: {
|
||||
up: 'messageActions:prev' as const,
|
||||
down: 'messageActions:next' as const,
|
||||
k: 'messageActions:prev' as const,
|
||||
j: 'messageActions:next' as const,
|
||||
// meta = cmd on macOS; super for kitty keyboard-protocol — bind both.
|
||||
'meta+up': 'messageActions:top' as const,
|
||||
'meta+down': 'messageActions:bottom' as const,
|
||||
'super+up': 'messageActions:top' as const,
|
||||
'super+down': 'messageActions:bottom' as const,
|
||||
// Mouse selection extends on shift+arrow (ScrollKeybindingHandler:573) when present —
|
||||
// correct layered UX: esc clears selection, then shift+↑ jumps.
|
||||
'shift+up': 'messageActions:prevUser' as const,
|
||||
'shift+down': 'messageActions:nextUser' as const,
|
||||
escape: 'messageActions:escape' as const,
|
||||
'ctrl+c': 'messageActions:ctrlc' as const,
|
||||
// Mirror MESSAGE_ACTIONS. Not imported — would pull React/ink into this config module.
|
||||
enter: 'messageActions:enter' as const,
|
||||
c: 'messageActions:c' as const,
|
||||
p: 'messageActions:p' as const,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Diff dialog navigation
|
||||
{
|
||||
context: 'DiffDialog',
|
||||
bindings: {
|
||||
escape: 'diff:dismiss',
|
||||
left: 'diff:previousSource',
|
||||
right: 'diff:nextSource',
|
||||
up: 'diff:previousFile',
|
||||
down: 'diff:nextFile',
|
||||
enter: 'diff:viewDetails',
|
||||
// Note: diff:back is handled by left arrow in detail mode
|
||||
},
|
||||
},
|
||||
// Model picker effort cycling (ant-only)
|
||||
{
|
||||
context: 'ModelPicker',
|
||||
bindings: {
|
||||
left: 'modelPicker:decreaseEffort',
|
||||
right: 'modelPicker:increaseEffort',
|
||||
},
|
||||
},
|
||||
// Select component navigation (used by /model, /resume, permission prompts, etc.)
|
||||
{
|
||||
context: 'Select',
|
||||
bindings: {
|
||||
up: 'select:previous',
|
||||
down: 'select:next',
|
||||
j: 'select:next',
|
||||
k: 'select:previous',
|
||||
'ctrl+n': 'select:next',
|
||||
'ctrl+p': 'select:previous',
|
||||
enter: 'select:accept',
|
||||
escape: 'select:cancel',
|
||||
},
|
||||
},
|
||||
// Plugin dialog actions (manage, browse, discover plugins)
|
||||
// Navigation (select:*) uses the Select context above
|
||||
{
|
||||
context: 'Plugin',
|
||||
bindings: {
|
||||
space: 'plugin:toggle',
|
||||
i: 'plugin:install',
|
||||
},
|
||||
},
|
||||
]
|
||||
472
src/keybindings/loadUserBindings.ts
Normal file
472
src/keybindings/loadUserBindings.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
120
src/keybindings/match.ts
Normal file
120
src/keybindings/match.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Key } from '../ink.js'
|
||||
import type { ParsedBinding, ParsedKeystroke } from './types.js'
|
||||
|
||||
/**
|
||||
* Modifier keys from Ink's Key type that we care about for matching.
|
||||
* Note: `fn` from Key is intentionally excluded as it's rarely used and
|
||||
* not commonly configurable in terminal applications.
|
||||
*/
|
||||
type InkModifiers = Pick<Key, 'ctrl' | 'shift' | 'meta' | 'super'>
|
||||
|
||||
/**
|
||||
* Extract modifiers from an Ink Key object.
|
||||
* This function ensures we're explicitly extracting the modifiers we care about.
|
||||
*/
|
||||
function getInkModifiers(key: Key): InkModifiers {
|
||||
return {
|
||||
ctrl: key.ctrl,
|
||||
shift: key.shift,
|
||||
meta: key.meta,
|
||||
super: key.super,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the normalized key name from Ink's Key + input.
|
||||
* Maps Ink's boolean flags (key.escape, key.return, etc.) to string names
|
||||
* that match our ParsedKeystroke.key format.
|
||||
*/
|
||||
export function getKeyName(input: string, key: Key): string | null {
|
||||
if (key.escape) return 'escape'
|
||||
if (key.return) return 'enter'
|
||||
if (key.tab) return 'tab'
|
||||
if (key.backspace) return 'backspace'
|
||||
if (key.delete) return 'delete'
|
||||
if (key.upArrow) return 'up'
|
||||
if (key.downArrow) return 'down'
|
||||
if (key.leftArrow) return 'left'
|
||||
if (key.rightArrow) return 'right'
|
||||
if (key.pageUp) return 'pageup'
|
||||
if (key.pageDown) return 'pagedown'
|
||||
if (key.wheelUp) return 'wheelup'
|
||||
if (key.wheelDown) return 'wheeldown'
|
||||
if (key.home) return 'home'
|
||||
if (key.end) return 'end'
|
||||
if (input.length === 1) return input.toLowerCase()
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all modifiers match between Ink Key and ParsedKeystroke.
|
||||
*
|
||||
* Alt and Meta: Ink historically set `key.meta` for Alt/Option. A `meta`
|
||||
* modifier in config is treated as an alias for `alt` — both match when
|
||||
* `key.meta` is true.
|
||||
*
|
||||
* Super (Cmd/Win): distinct from alt/meta. Only arrives via the kitty
|
||||
* keyboard protocol on supporting terminals. A `cmd`/`super` binding will
|
||||
* simply never fire on terminals that don't send it.
|
||||
*/
|
||||
function modifiersMatch(
|
||||
inkMods: InkModifiers,
|
||||
target: ParsedKeystroke,
|
||||
): boolean {
|
||||
// Check ctrl modifier
|
||||
if (inkMods.ctrl !== target.ctrl) return false
|
||||
|
||||
// Check shift modifier
|
||||
if (inkMods.shift !== target.shift) return false
|
||||
|
||||
// Alt and meta both map to key.meta in Ink (terminal limitation)
|
||||
// So we check if EITHER alt OR meta is required in target
|
||||
const targetNeedsMeta = target.alt || target.meta
|
||||
if (inkMods.meta !== targetNeedsMeta) return false
|
||||
|
||||
// Super (cmd/win) is a distinct modifier from alt/meta
|
||||
if (inkMods.super !== target.super) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ParsedKeystroke matches the given Ink input + Key.
|
||||
*
|
||||
* The display text will show platform-appropriate names (opt on macOS, alt elsewhere).
|
||||
*/
|
||||
export function matchesKeystroke(
|
||||
input: string,
|
||||
key: Key,
|
||||
target: ParsedKeystroke,
|
||||
): boolean {
|
||||
const keyName = getKeyName(input, key)
|
||||
if (keyName !== target.key) return false
|
||||
|
||||
const inkMods = getInkModifiers(key)
|
||||
|
||||
// QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
|
||||
// This is a legacy behavior from how escape sequences work in terminals.
|
||||
// We need to ignore the meta modifier when matching the escape key itself,
|
||||
// otherwise bindings like "escape" (without modifiers) would never match.
|
||||
if (key.escape) {
|
||||
return modifiersMatch({ ...inkMods, meta: false }, target)
|
||||
}
|
||||
|
||||
return modifiersMatch(inkMods, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Ink's Key + input matches a parsed binding's first keystroke.
|
||||
* For single-keystroke bindings only (Phase 1).
|
||||
*/
|
||||
export function matchesBinding(
|
||||
input: string,
|
||||
key: Key,
|
||||
binding: ParsedBinding,
|
||||
): boolean {
|
||||
if (binding.chord.length !== 1) return false
|
||||
const keystroke = binding.chord[0]
|
||||
if (!keystroke) return false
|
||||
return matchesKeystroke(input, key, keystroke)
|
||||
}
|
||||
203
src/keybindings/parser.ts
Normal file
203
src/keybindings/parser.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
Chord,
|
||||
KeybindingBlock,
|
||||
ParsedBinding,
|
||||
ParsedKeystroke,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Parse a keystroke string like "ctrl+shift+k" into a ParsedKeystroke.
|
||||
* Supports various modifier aliases (ctrl/control, alt/opt/option/meta,
|
||||
* cmd/command/super/win).
|
||||
*/
|
||||
export function parseKeystroke(input: string): ParsedKeystroke {
|
||||
const parts = input.split('+')
|
||||
const keystroke: ParsedKeystroke = {
|
||||
key: '',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
super: false,
|
||||
}
|
||||
for (const part of parts) {
|
||||
const lower = part.toLowerCase()
|
||||
switch (lower) {
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
keystroke.ctrl = true
|
||||
break
|
||||
case 'alt':
|
||||
case 'opt':
|
||||
case 'option':
|
||||
keystroke.alt = true
|
||||
break
|
||||
case 'shift':
|
||||
keystroke.shift = true
|
||||
break
|
||||
case 'meta':
|
||||
keystroke.meta = true
|
||||
break
|
||||
case 'cmd':
|
||||
case 'command':
|
||||
case 'super':
|
||||
case 'win':
|
||||
keystroke.super = true
|
||||
break
|
||||
case 'esc':
|
||||
keystroke.key = 'escape'
|
||||
break
|
||||
case 'return':
|
||||
keystroke.key = 'enter'
|
||||
break
|
||||
case 'space':
|
||||
keystroke.key = ' '
|
||||
break
|
||||
case '↑':
|
||||
keystroke.key = 'up'
|
||||
break
|
||||
case '↓':
|
||||
keystroke.key = 'down'
|
||||
break
|
||||
case '←':
|
||||
keystroke.key = 'left'
|
||||
break
|
||||
case '→':
|
||||
keystroke.key = 'right'
|
||||
break
|
||||
default:
|
||||
keystroke.key = lower
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keystroke
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a chord string like "ctrl+k ctrl+s" into an array of ParsedKeystrokes.
|
||||
*/
|
||||
export function parseChord(input: string): Chord {
|
||||
// A lone space character IS the space key binding, not a separator
|
||||
if (input === ' ') return [parseKeystroke('space')]
|
||||
return input.trim().split(/\s+/).map(parseKeystroke)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ParsedKeystroke to its canonical string representation for display.
|
||||
*/
|
||||
export function keystrokeToString(ks: ParsedKeystroke): string {
|
||||
const parts: string[] = []
|
||||
if (ks.ctrl) parts.push('ctrl')
|
||||
if (ks.alt) parts.push('alt')
|
||||
if (ks.shift) parts.push('shift')
|
||||
if (ks.meta) parts.push('meta')
|
||||
if (ks.super) parts.push('cmd')
|
||||
// Use readable names for display
|
||||
const displayKey = keyToDisplayName(ks.key)
|
||||
parts.push(displayKey)
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* Map internal key names to human-readable display names.
|
||||
*/
|
||||
function keyToDisplayName(key: string): string {
|
||||
switch (key) {
|
||||
case 'escape':
|
||||
return 'Esc'
|
||||
case ' ':
|
||||
return 'Space'
|
||||
case 'tab':
|
||||
return 'tab'
|
||||
case 'enter':
|
||||
return 'Enter'
|
||||
case 'backspace':
|
||||
return 'Backspace'
|
||||
case 'delete':
|
||||
return 'Delete'
|
||||
case 'up':
|
||||
return '↑'
|
||||
case 'down':
|
||||
return '↓'
|
||||
case 'left':
|
||||
return '←'
|
||||
case 'right':
|
||||
return '→'
|
||||
case 'pageup':
|
||||
return 'PageUp'
|
||||
case 'pagedown':
|
||||
return 'PageDown'
|
||||
case 'home':
|
||||
return 'Home'
|
||||
case 'end':
|
||||
return 'End'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Chord to its canonical string representation for display.
|
||||
*/
|
||||
export function chordToString(chord: Chord): string {
|
||||
return chord.map(keystrokeToString).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Display platform type - a subset of Platform that we care about for display.
|
||||
* WSL and unknown are treated as linux for display purposes.
|
||||
*/
|
||||
type DisplayPlatform = 'macos' | 'windows' | 'linux' | 'wsl' | 'unknown'
|
||||
|
||||
/**
|
||||
* Convert a ParsedKeystroke to a platform-appropriate display string.
|
||||
* Uses "opt" for alt on macOS, "alt" elsewhere.
|
||||
*/
|
||||
export function keystrokeToDisplayString(
|
||||
ks: ParsedKeystroke,
|
||||
platform: DisplayPlatform = 'linux',
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
if (ks.ctrl) parts.push('ctrl')
|
||||
// Alt/meta are equivalent in terminals, show platform-appropriate name
|
||||
if (ks.alt || ks.meta) {
|
||||
// Only macOS uses "opt", all other platforms use "alt"
|
||||
parts.push(platform === 'macos' ? 'opt' : 'alt')
|
||||
}
|
||||
if (ks.shift) parts.push('shift')
|
||||
if (ks.super) {
|
||||
parts.push(platform === 'macos' ? 'cmd' : 'super')
|
||||
}
|
||||
// Use readable names for display
|
||||
const displayKey = keyToDisplayName(ks.key)
|
||||
parts.push(displayKey)
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Chord to a platform-appropriate display string.
|
||||
*/
|
||||
export function chordToDisplayString(
|
||||
chord: Chord,
|
||||
platform: DisplayPlatform = 'linux',
|
||||
): string {
|
||||
return chord.map(ks => keystrokeToDisplayString(ks, platform)).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse keybinding blocks (from JSON config) into a flat list of ParsedBindings.
|
||||
*/
|
||||
export function parseBindings(blocks: KeybindingBlock[]): ParsedBinding[] {
|
||||
const bindings: ParsedBinding[] = []
|
||||
for (const block of blocks) {
|
||||
for (const [key, action] of Object.entries(block.bindings)) {
|
||||
bindings.push({
|
||||
chord: parseChord(key),
|
||||
action,
|
||||
context: block.context,
|
||||
})
|
||||
}
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
127
src/keybindings/reservedShortcuts.ts
Normal file
127
src/keybindings/reservedShortcuts.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { getPlatform } from '../utils/platform.js'
|
||||
|
||||
/**
|
||||
* Shortcuts that are typically intercepted by the OS, terminal, or shell
|
||||
* and will likely never reach the application.
|
||||
*/
|
||||
export type ReservedShortcut = {
|
||||
key: string
|
||||
reason: string
|
||||
severity: 'error' | 'warning'
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcuts that cannot be rebound - they are hardcoded in Claude Code.
|
||||
*/
|
||||
export const NON_REBINDABLE: ReservedShortcut[] = [
|
||||
{
|
||||
key: 'ctrl+c',
|
||||
reason: 'Cannot be rebound - used for interrupt/exit (hardcoded)',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
key: 'ctrl+d',
|
||||
reason: 'Cannot be rebound - used for exit (hardcoded)',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
key: 'ctrl+m',
|
||||
reason:
|
||||
'Cannot be rebound - identical to Enter in terminals (both send CR)',
|
||||
severity: 'error',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Terminal control shortcuts that are intercepted by the terminal/OS.
|
||||
* These will likely never reach the application.
|
||||
*
|
||||
* Note: ctrl+s (XOFF) and ctrl+q (XON) are NOT included here because:
|
||||
* - Most modern terminals disable flow control by default
|
||||
* - We use ctrl+s for the stash feature
|
||||
*/
|
||||
export const TERMINAL_RESERVED: ReservedShortcut[] = [
|
||||
{
|
||||
key: 'ctrl+z',
|
||||
reason: 'Unix process suspend (SIGTSTP)',
|
||||
severity: 'warning',
|
||||
},
|
||||
{
|
||||
key: 'ctrl+\\',
|
||||
reason: 'Terminal quit signal (SIGQUIT)',
|
||||
severity: 'error',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* macOS-specific shortcuts that the OS intercepts.
|
||||
*/
|
||||
export const MACOS_RESERVED: ReservedShortcut[] = [
|
||||
{ key: 'cmd+c', reason: 'macOS system copy', severity: 'error' },
|
||||
{ key: 'cmd+v', reason: 'macOS system paste', severity: 'error' },
|
||||
{ key: 'cmd+x', reason: 'macOS system cut', severity: 'error' },
|
||||
{ key: 'cmd+q', reason: 'macOS quit application', severity: 'error' },
|
||||
{ key: 'cmd+w', reason: 'macOS close window/tab', severity: 'error' },
|
||||
{ key: 'cmd+tab', reason: 'macOS app switcher', severity: 'error' },
|
||||
{ key: 'cmd+space', reason: 'macOS Spotlight', severity: 'error' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Get all reserved shortcuts for the current platform.
|
||||
* Includes non-rebindable shortcuts and terminal-reserved shortcuts.
|
||||
*/
|
||||
export function getReservedShortcuts(): ReservedShortcut[] {
|
||||
const platform = getPlatform()
|
||||
// Non-rebindable shortcuts first (highest priority)
|
||||
const reserved = [...NON_REBINDABLE, ...TERMINAL_RESERVED]
|
||||
|
||||
if (platform === 'macos') {
|
||||
reserved.push(...MACOS_RESERVED)
|
||||
}
|
||||
|
||||
return reserved
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a key string for comparison (lowercase, sorted modifiers).
|
||||
* Chords (space-separated steps like "ctrl+x ctrl+b") are normalized
|
||||
* per-step — splitting on '+' first would mangle "x ctrl" into a mainKey
|
||||
* overwritten by the next step, collapsing the chord into its last key.
|
||||
*/
|
||||
export function normalizeKeyForComparison(key: string): string {
|
||||
return key.trim().split(/\s+/).map(normalizeStep).join(' ')
|
||||
}
|
||||
|
||||
function normalizeStep(step: string): string {
|
||||
const parts = step.split('+')
|
||||
const modifiers: string[] = []
|
||||
let mainKey = ''
|
||||
|
||||
for (const part of parts) {
|
||||
const lower = part.trim().toLowerCase()
|
||||
if (
|
||||
[
|
||||
'ctrl',
|
||||
'control',
|
||||
'alt',
|
||||
'opt',
|
||||
'option',
|
||||
'meta',
|
||||
'cmd',
|
||||
'command',
|
||||
'shift',
|
||||
].includes(lower)
|
||||
) {
|
||||
// Normalize modifier names
|
||||
if (lower === 'control') modifiers.push('ctrl')
|
||||
else if (lower === 'option' || lower === 'opt') modifiers.push('alt')
|
||||
else if (lower === 'command' || lower === 'cmd') modifiers.push('cmd')
|
||||
else modifiers.push(lower)
|
||||
} else {
|
||||
mainKey = lower
|
||||
}
|
||||
}
|
||||
|
||||
modifiers.sort()
|
||||
return [...modifiers, mainKey].join('+')
|
||||
}
|
||||
244
src/keybindings/resolver.ts
Normal file
244
src/keybindings/resolver.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Key } from '../ink.js'
|
||||
import { getKeyName, matchesBinding } from './match.js'
|
||||
import { chordToString } from './parser.js'
|
||||
import type {
|
||||
KeybindingContextName,
|
||||
ParsedBinding,
|
||||
ParsedKeystroke,
|
||||
} from './types.js'
|
||||
|
||||
export type ResolveResult =
|
||||
| { type: 'match'; action: string }
|
||||
| { type: 'none' }
|
||||
| { type: 'unbound' }
|
||||
|
||||
export type ChordResolveResult =
|
||||
| { type: 'match'; action: string }
|
||||
| { type: 'none' }
|
||||
| { type: 'unbound' }
|
||||
| { type: 'chord_started'; pending: ParsedKeystroke[] }
|
||||
| { type: 'chord_cancelled' }
|
||||
|
||||
/**
|
||||
* Resolve a key input to an action.
|
||||
* Pure function - no state, no side effects, just matching logic.
|
||||
*
|
||||
* @param input - The character input from Ink
|
||||
* @param key - The Key object from Ink with modifier flags
|
||||
* @param activeContexts - Array of currently active contexts (e.g., ['Chat', 'Global'])
|
||||
* @param bindings - All parsed bindings to search through
|
||||
* @returns The resolution result
|
||||
*/
|
||||
export function resolveKey(
|
||||
input: string,
|
||||
key: Key,
|
||||
activeContexts: KeybindingContextName[],
|
||||
bindings: ParsedBinding[],
|
||||
): ResolveResult {
|
||||
// Find matching bindings (last one wins for user overrides)
|
||||
let match: ParsedBinding | undefined
|
||||
const ctxSet = new Set(activeContexts)
|
||||
|
||||
for (const binding of bindings) {
|
||||
// Phase 1: Only single-keystroke bindings
|
||||
if (binding.chord.length !== 1) continue
|
||||
if (!ctxSet.has(binding.context)) continue
|
||||
|
||||
if (matchesBinding(input, key, binding)) {
|
||||
match = binding
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
return { type: 'none' }
|
||||
}
|
||||
|
||||
if (match.action === null) {
|
||||
return { type: 'unbound' }
|
||||
}
|
||||
|
||||
return { type: 'match', action: match.action }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for an action from bindings (e.g., "ctrl+t" for "app:toggleTodos").
|
||||
* Searches in reverse order so user overrides take precedence.
|
||||
*/
|
||||
export function getBindingDisplayText(
|
||||
action: string,
|
||||
context: KeybindingContextName,
|
||||
bindings: ParsedBinding[],
|
||||
): string | undefined {
|
||||
// Find the last binding for this action in this context
|
||||
const binding = bindings.findLast(
|
||||
b => b.action === action && b.context === context,
|
||||
)
|
||||
return binding ? chordToString(binding.chord) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ParsedKeystroke from Ink's input/key.
|
||||
*/
|
||||
function buildKeystroke(input: string, key: Key): ParsedKeystroke | null {
|
||||
const keyName = getKeyName(input, key)
|
||||
if (!keyName) return null
|
||||
|
||||
// QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
|
||||
// This is legacy terminal behavior - we should NOT record this as a modifier
|
||||
// for the escape key itself, otherwise chord matching will fail.
|
||||
const effectiveMeta = key.escape ? false : key.meta
|
||||
|
||||
return {
|
||||
key: keyName,
|
||||
ctrl: key.ctrl,
|
||||
alt: effectiveMeta,
|
||||
shift: key.shift,
|
||||
meta: effectiveMeta,
|
||||
super: key.super,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two ParsedKeystrokes for equality. Collapses alt/meta into
|
||||
* one logical modifier — legacy terminals can't distinguish them (see
|
||||
* match.ts modifiersMatch), so "alt+k" and "meta+k" are the same key.
|
||||
* Super (cmd/win) is distinct — only arrives via kitty keyboard protocol.
|
||||
*/
|
||||
export function keystrokesEqual(
|
||||
a: ParsedKeystroke,
|
||||
b: ParsedKeystroke,
|
||||
): boolean {
|
||||
return (
|
||||
a.key === b.key &&
|
||||
a.ctrl === b.ctrl &&
|
||||
a.shift === b.shift &&
|
||||
(a.alt || a.meta) === (b.alt || b.meta) &&
|
||||
a.super === b.super
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a chord prefix matches the beginning of a binding's chord.
|
||||
*/
|
||||
function chordPrefixMatches(
|
||||
prefix: ParsedKeystroke[],
|
||||
binding: ParsedBinding,
|
||||
): boolean {
|
||||
if (prefix.length >= binding.chord.length) return false
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
const prefixKey = prefix[i]
|
||||
const bindingKey = binding.chord[i]
|
||||
if (!prefixKey || !bindingKey) return false
|
||||
if (!keystrokesEqual(prefixKey, bindingKey)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a full chord matches a binding's chord.
|
||||
*/
|
||||
function chordExactlyMatches(
|
||||
chord: ParsedKeystroke[],
|
||||
binding: ParsedBinding,
|
||||
): boolean {
|
||||
if (chord.length !== binding.chord.length) return false
|
||||
for (let i = 0; i < chord.length; i++) {
|
||||
const chordKey = chord[i]
|
||||
const bindingKey = binding.chord[i]
|
||||
if (!chordKey || !bindingKey) return false
|
||||
if (!keystrokesEqual(chordKey, bindingKey)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a key with chord state support.
|
||||
*
|
||||
* This function handles multi-keystroke chord bindings like "ctrl+k ctrl+s".
|
||||
*
|
||||
* @param input - The character input from Ink
|
||||
* @param key - The Key object from Ink with modifier flags
|
||||
* @param activeContexts - Array of currently active contexts
|
||||
* @param bindings - All parsed bindings
|
||||
* @param pending - Current chord state (null if not in a chord)
|
||||
* @returns Resolution result with chord state
|
||||
*/
|
||||
export function resolveKeyWithChordState(
|
||||
input: string,
|
||||
key: Key,
|
||||
activeContexts: KeybindingContextName[],
|
||||
bindings: ParsedBinding[],
|
||||
pending: ParsedKeystroke[] | null,
|
||||
): ChordResolveResult {
|
||||
// Cancel chord on escape
|
||||
if (key.escape && pending !== null) {
|
||||
return { type: 'chord_cancelled' }
|
||||
}
|
||||
|
||||
// Build current keystroke
|
||||
const currentKeystroke = buildKeystroke(input, key)
|
||||
if (!currentKeystroke) {
|
||||
if (pending !== null) {
|
||||
return { type: 'chord_cancelled' }
|
||||
}
|
||||
return { type: 'none' }
|
||||
}
|
||||
|
||||
// Build the full chord sequence to test
|
||||
const testChord = pending
|
||||
? [...pending, currentKeystroke]
|
||||
: [currentKeystroke]
|
||||
|
||||
// Filter bindings by active contexts (Set lookup: O(n) instead of O(n·m))
|
||||
const ctxSet = new Set(activeContexts)
|
||||
const contextBindings = bindings.filter(b => ctxSet.has(b.context))
|
||||
|
||||
// Check if this could be a prefix for longer chords. Group by chord
|
||||
// string so a later null-override shadows the default it unbinds —
|
||||
// otherwise null-unbinding `ctrl+x ctrl+k` still makes `ctrl+x` enter
|
||||
// chord-wait and the single-key binding on the prefix never fires.
|
||||
const chordWinners = new Map<string, string | null>()
|
||||
for (const binding of contextBindings) {
|
||||
if (
|
||||
binding.chord.length > testChord.length &&
|
||||
chordPrefixMatches(testChord, binding)
|
||||
) {
|
||||
chordWinners.set(chordToString(binding.chord), binding.action)
|
||||
}
|
||||
}
|
||||
let hasLongerChords = false
|
||||
for (const action of chordWinners.values()) {
|
||||
if (action !== null) {
|
||||
hasLongerChords = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If this keystroke could start a longer chord, prefer that
|
||||
// (even if there's an exact single-key match)
|
||||
if (hasLongerChords) {
|
||||
return { type: 'chord_started', pending: testChord }
|
||||
}
|
||||
|
||||
// Check for exact matches (last one wins)
|
||||
let exactMatch: ParsedBinding | undefined
|
||||
for (const binding of contextBindings) {
|
||||
if (chordExactlyMatches(testChord, binding)) {
|
||||
exactMatch = binding
|
||||
}
|
||||
}
|
||||
|
||||
if (exactMatch) {
|
||||
if (exactMatch.action === null) {
|
||||
return { type: 'unbound' }
|
||||
}
|
||||
return { type: 'match', action: exactMatch.action }
|
||||
}
|
||||
|
||||
// No match and no potential longer chords
|
||||
if (pending !== null) {
|
||||
return { type: 'chord_cancelled' }
|
||||
}
|
||||
|
||||
return { type: 'none' }
|
||||
}
|
||||
236
src/keybindings/schema.ts
Normal file
236
src/keybindings/schema.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Zod schema for keybindings.json configuration.
|
||||
* Used for validation and JSON schema generation.
|
||||
*/
|
||||
|
||||
import { z } from 'zod/v4'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
|
||||
/**
|
||||
* Valid context names where keybindings can be applied.
|
||||
*/
|
||||
export const KEYBINDING_CONTEXTS = [
|
||||
'Global',
|
||||
'Chat',
|
||||
'Autocomplete',
|
||||
'Confirmation',
|
||||
'Help',
|
||||
'Transcript',
|
||||
'HistorySearch',
|
||||
'Task',
|
||||
'ThemePicker',
|
||||
'Settings',
|
||||
'Tabs',
|
||||
// New contexts for keybindings migration
|
||||
'Attachments',
|
||||
'Footer',
|
||||
'MessageSelector',
|
||||
'DiffDialog',
|
||||
'ModelPicker',
|
||||
'Select',
|
||||
'Plugin',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Human-readable descriptions for each keybinding context.
|
||||
*/
|
||||
export const KEYBINDING_CONTEXT_DESCRIPTIONS: Record<
|
||||
(typeof KEYBINDING_CONTEXTS)[number],
|
||||
string
|
||||
> = {
|
||||
Global: 'Active everywhere, regardless of focus',
|
||||
Chat: 'When the chat input is focused',
|
||||
Autocomplete: 'When autocomplete menu is visible',
|
||||
Confirmation: 'When a confirmation/permission dialog is shown',
|
||||
Help: 'When the help overlay is open',
|
||||
Transcript: 'When viewing the transcript',
|
||||
HistorySearch: 'When searching command history (ctrl+r)',
|
||||
Task: 'When a task/agent is running in the foreground',
|
||||
ThemePicker: 'When the theme picker is open',
|
||||
Settings: 'When the settings menu is open',
|
||||
Tabs: 'When tab navigation is active',
|
||||
Attachments: 'When navigating image attachments in a select dialog',
|
||||
Footer: 'When footer indicators are focused',
|
||||
MessageSelector: 'When the message selector (rewind) is open',
|
||||
DiffDialog: 'When the diff dialog is open',
|
||||
ModelPicker: 'When the model picker is open',
|
||||
Select: 'When a select/list component is focused',
|
||||
Plugin: 'When the plugin dialog is open',
|
||||
}
|
||||
|
||||
/**
|
||||
* All valid keybinding action identifiers.
|
||||
*/
|
||||
export const KEYBINDING_ACTIONS = [
|
||||
// App-level actions (Global context)
|
||||
'app:interrupt',
|
||||
'app:exit',
|
||||
'app:toggleTodos',
|
||||
'app:toggleTranscript',
|
||||
'app:toggleBrief',
|
||||
'app:toggleTeammatePreview',
|
||||
'app:toggleTerminal',
|
||||
'app:redraw',
|
||||
'app:globalSearch',
|
||||
'app:quickOpen',
|
||||
// History navigation
|
||||
'history:search',
|
||||
'history:previous',
|
||||
'history:next',
|
||||
// Chat input actions
|
||||
'chat:cancel',
|
||||
'chat:killAgents',
|
||||
'chat:cycleMode',
|
||||
'chat:modelPicker',
|
||||
'chat:fastMode',
|
||||
'chat:thinkingToggle',
|
||||
'chat:submit',
|
||||
'chat:newline',
|
||||
'chat:undo',
|
||||
'chat:externalEditor',
|
||||
'chat:stash',
|
||||
'chat:imagePaste',
|
||||
'chat:messageActions',
|
||||
// Autocomplete menu actions
|
||||
'autocomplete:accept',
|
||||
'autocomplete:dismiss',
|
||||
'autocomplete:previous',
|
||||
'autocomplete:next',
|
||||
// Confirmation dialog actions
|
||||
'confirm:yes',
|
||||
'confirm:no',
|
||||
'confirm:previous',
|
||||
'confirm:next',
|
||||
'confirm:nextField',
|
||||
'confirm:previousField',
|
||||
'confirm:cycleMode',
|
||||
'confirm:toggle',
|
||||
'confirm:toggleExplanation',
|
||||
// Tabs navigation actions
|
||||
'tabs:next',
|
||||
'tabs:previous',
|
||||
// Transcript viewer actions
|
||||
'transcript:toggleShowAll',
|
||||
'transcript:exit',
|
||||
// History search actions
|
||||
'historySearch:next',
|
||||
'historySearch:accept',
|
||||
'historySearch:cancel',
|
||||
'historySearch:execute',
|
||||
// Task/agent actions
|
||||
'task:background',
|
||||
// Theme picker actions
|
||||
'theme:toggleSyntaxHighlighting',
|
||||
// Help menu actions
|
||||
'help:dismiss',
|
||||
// Attachment navigation (select dialog image attachments)
|
||||
'attachments:next',
|
||||
'attachments:previous',
|
||||
'attachments:remove',
|
||||
'attachments:exit',
|
||||
// Footer indicator actions
|
||||
'footer:up',
|
||||
'footer:down',
|
||||
'footer:next',
|
||||
'footer:previous',
|
||||
'footer:openSelected',
|
||||
'footer:clearSelection',
|
||||
'footer:close',
|
||||
// Message selector (rewind) actions
|
||||
'messageSelector:up',
|
||||
'messageSelector:down',
|
||||
'messageSelector:top',
|
||||
'messageSelector:bottom',
|
||||
'messageSelector:select',
|
||||
// Diff dialog actions
|
||||
'diff:dismiss',
|
||||
'diff:previousSource',
|
||||
'diff:nextSource',
|
||||
'diff:back',
|
||||
'diff:viewDetails',
|
||||
'diff:previousFile',
|
||||
'diff:nextFile',
|
||||
// Model picker actions (ant-only)
|
||||
'modelPicker:decreaseEffort',
|
||||
'modelPicker:increaseEffort',
|
||||
// Select component actions (distinct from confirm: to avoid collisions)
|
||||
'select:next',
|
||||
'select:previous',
|
||||
'select:accept',
|
||||
'select:cancel',
|
||||
// Plugin dialog actions
|
||||
'plugin:toggle',
|
||||
'plugin:install',
|
||||
// Permission dialog actions
|
||||
'permission:toggleDebug',
|
||||
// Settings config panel actions
|
||||
'settings:search',
|
||||
'settings:retry',
|
||||
'settings:close',
|
||||
// Voice actions
|
||||
'voice:pushToTalk',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Schema for a single keybinding block.
|
||||
*/
|
||||
export const KeybindingBlockSchema = lazySchema(() =>
|
||||
z
|
||||
.object({
|
||||
context: z
|
||||
.enum(KEYBINDING_CONTEXTS)
|
||||
.describe(
|
||||
'UI context where these bindings apply. Global bindings work everywhere.',
|
||||
),
|
||||
bindings: z
|
||||
.record(
|
||||
z
|
||||
.string()
|
||||
.describe('Keystroke pattern (e.g., "ctrl+k", "shift+tab")'),
|
||||
z
|
||||
.union([
|
||||
z.enum(KEYBINDING_ACTIONS),
|
||||
z
|
||||
.string()
|
||||
.regex(/^command:[a-zA-Z0-9:\-_]+$/)
|
||||
.describe(
|
||||
'Command binding (e.g., "command:help", "command:compact"). Executes the slash command as if typed.',
|
||||
),
|
||||
z.null().describe('Set to null to unbind a default shortcut'),
|
||||
])
|
||||
.describe(
|
||||
'Action to trigger, command to invoke, or null to unbind',
|
||||
),
|
||||
)
|
||||
.describe('Map of keystroke patterns to actions'),
|
||||
})
|
||||
.describe('A block of keybindings for a specific context'),
|
||||
)
|
||||
|
||||
/**
|
||||
* Schema for the entire keybindings.json file.
|
||||
* Uses object wrapper format with optional $schema and $docs metadata.
|
||||
*/
|
||||
export const KeybindingsSchema = lazySchema(() =>
|
||||
z
|
||||
.object({
|
||||
$schema: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('JSON Schema URL for editor validation'),
|
||||
$docs: z.string().optional().describe('Documentation URL'),
|
||||
bindings: z
|
||||
.array(KeybindingBlockSchema())
|
||||
.describe('Array of keybinding blocks by context'),
|
||||
})
|
||||
.describe(
|
||||
'Claude Code keybindings configuration. Customize keyboard shortcuts by context.',
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* TypeScript types derived from the schema.
|
||||
*/
|
||||
export type KeybindingsSchemaType = z.infer<
|
||||
ReturnType<typeof KeybindingsSchema>
|
||||
>
|
||||
63
src/keybindings/shortcutFormat.ts
Normal file
63
src/keybindings/shortcutFormat.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import { loadKeybindingsSync } from './loadUserBindings.js'
|
||||
import { getBindingDisplayText } from './resolver.js'
|
||||
import type { KeybindingContextName } from './types.js'
|
||||
|
||||
// TODO(keybindings-migration): Remove fallback parameter after migration is
|
||||
// complete and we've confirmed no 'keybinding_fallback_used' events are being
|
||||
// logged. The fallback exists as a safety net during migration - if bindings
|
||||
// fail to load or an action isn't found, we fall back to hardcoded values.
|
||||
// Once stable, callers should be able to trust that getBindingDisplayText
|
||||
// always returns a value for known actions, and we can remove this defensive
|
||||
// pattern.
|
||||
|
||||
// Track which action+context pairs have already logged a fallback event
|
||||
// to avoid duplicate events from repeated calls in non-React contexts.
|
||||
const LOGGED_FALLBACKS = new Set<string>()
|
||||
|
||||
/**
|
||||
* Get the display text for a configured shortcut without React hooks.
|
||||
* Use this in non-React contexts (commands, services, etc.).
|
||||
*
|
||||
* This lives in its own module (not useShortcutDisplay.ts) so that
|
||||
* non-React callers like query/stopHooks.ts don't pull React into their
|
||||
* module graph via the sibling hook.
|
||||
*
|
||||
* @param action - The action name (e.g., 'app:toggleTranscript')
|
||||
* @param context - The keybinding context (e.g., 'Global')
|
||||
* @param fallback - Fallback text if binding not found
|
||||
* @returns The configured shortcut display text
|
||||
*
|
||||
* @example
|
||||
* const expandShortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o')
|
||||
* // Returns the user's configured binding, or 'ctrl+o' as default
|
||||
*/
|
||||
export function getShortcutDisplay(
|
||||
action: string,
|
||||
context: KeybindingContextName,
|
||||
fallback: string,
|
||||
): string {
|
||||
const bindings = loadKeybindingsSync()
|
||||
const resolved = getBindingDisplayText(action, context, bindings)
|
||||
if (resolved === undefined) {
|
||||
const key = `${action}:${context}`
|
||||
if (!LOGGED_FALLBACKS.has(key)) {
|
||||
LOGGED_FALLBACKS.add(key)
|
||||
logEvent('tengu_keybinding_fallback_used', {
|
||||
action:
|
||||
action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
context:
|
||||
context as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
fallback:
|
||||
fallback as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
reason:
|
||||
'action_not_found' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
52
src/keybindings/template.ts
Normal file
52
src/keybindings/template.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Keybindings template generator.
|
||||
* Generates a well-documented template file for ~/.claude/keybindings.json
|
||||
*/
|
||||
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { DEFAULT_BINDINGS } from './defaultBindings.js'
|
||||
import {
|
||||
NON_REBINDABLE,
|
||||
normalizeKeyForComparison,
|
||||
} from './reservedShortcuts.js'
|
||||
import type { KeybindingBlock } from './types.js'
|
||||
|
||||
/**
|
||||
* Filter out reserved shortcuts that cannot be rebound.
|
||||
* These would cause /doctor to warn, so we exclude them from the template.
|
||||
*/
|
||||
function filterReservedShortcuts(blocks: KeybindingBlock[]): KeybindingBlock[] {
|
||||
const reservedKeys = new Set(
|
||||
NON_REBINDABLE.map(r => normalizeKeyForComparison(r.key)),
|
||||
)
|
||||
|
||||
return blocks
|
||||
.map(block => {
|
||||
const filteredBindings: Record<string, string | null> = {}
|
||||
for (const [key, action] of Object.entries(block.bindings)) {
|
||||
if (!reservedKeys.has(normalizeKeyForComparison(key))) {
|
||||
filteredBindings[key] = action
|
||||
}
|
||||
}
|
||||
return { context: block.context, bindings: filteredBindings }
|
||||
})
|
||||
.filter(block => Object.keys(block.bindings).length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a template keybindings.json file content.
|
||||
* Creates a fully valid JSON file with all default bindings that users can customize.
|
||||
*/
|
||||
export function generateKeybindingsTemplate(): string {
|
||||
// Filter out reserved shortcuts that cannot be rebound
|
||||
const bindings = filterReservedShortcuts(DEFAULT_BINDINGS)
|
||||
|
||||
// Format as object wrapper with bindings array
|
||||
const config = {
|
||||
$schema: 'https://www.schemastore.org/claude-code-keybindings.json',
|
||||
$docs: 'https://code.claude.com/docs/en/keybindings',
|
||||
bindings,
|
||||
}
|
||||
|
||||
return jsonStringify(config, null, 2) + '\n'
|
||||
}
|
||||
196
src/keybindings/useKeybinding.ts
Normal file
196
src/keybindings/useKeybinding.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import type { InputEvent } from '../ink/events/input-event.js'
|
||||
import { type Key, useInput } from '../ink.js'
|
||||
import { useOptionalKeybindingContext } from './KeybindingContext.js'
|
||||
import type { KeybindingContextName } from './types.js'
|
||||
|
||||
type Options = {
|
||||
/** Which context this binding belongs to (default: 'Global') */
|
||||
context?: KeybindingContextName
|
||||
/** Only handle when active (like useInput's isActive) */
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Ink-native hook for handling a keybinding.
|
||||
*
|
||||
* The handler stays in the component (React way).
|
||||
* The binding (keystroke → action) comes from config.
|
||||
*
|
||||
* Supports chord sequences (e.g., "ctrl+k ctrl+s"). When a chord is started,
|
||||
* the hook will manage the pending state automatically.
|
||||
*
|
||||
* Uses stopImmediatePropagation() to prevent other handlers from firing
|
||||
* once this binding is handled.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useKeybinding('app:toggleTodos', () => {
|
||||
* setShowTodos(prev => !prev)
|
||||
* }, { context: 'Global' })
|
||||
* ```
|
||||
*/
|
||||
export function useKeybinding(
|
||||
action: string,
|
||||
handler: () => void | false | Promise<void>,
|
||||
options: Options = {},
|
||||
): void {
|
||||
const { context = 'Global', isActive = true } = options
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
|
||||
// Register handler with the context for ChordInterceptor to invoke
|
||||
useEffect(() => {
|
||||
if (!keybindingContext || !isActive) return
|
||||
return keybindingContext.registerHandler({ action, context, handler })
|
||||
}, [action, context, handler, keybindingContext, isActive])
|
||||
|
||||
const handleInput = useCallback(
|
||||
(input: string, key: Key, event: InputEvent) => {
|
||||
// If no keybinding context available, skip resolution
|
||||
if (!keybindingContext) return
|
||||
|
||||
// Build context list: registered active contexts + this context + Global
|
||||
// More specific contexts (registered ones) take precedence over Global
|
||||
const contextsToCheck: KeybindingContextName[] = [
|
||||
...keybindingContext.activeContexts,
|
||||
context,
|
||||
'Global',
|
||||
]
|
||||
// Deduplicate while preserving order (first occurrence wins for priority)
|
||||
const uniqueContexts = [...new Set(contextsToCheck)]
|
||||
|
||||
const result = keybindingContext.resolve(input, key, uniqueContexts)
|
||||
|
||||
switch (result.type) {
|
||||
case 'match':
|
||||
// Chord completed (if any) - clear pending state
|
||||
keybindingContext.setPendingChord(null)
|
||||
if (result.action === action) {
|
||||
if (handler() !== false) {
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'chord_started':
|
||||
// User started a chord sequence - update pending state
|
||||
keybindingContext.setPendingChord(result.pending)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
case 'chord_cancelled':
|
||||
// Chord was cancelled (escape or invalid key)
|
||||
keybindingContext.setPendingChord(null)
|
||||
break
|
||||
case 'unbound':
|
||||
// Explicitly unbound - clear any pending chord
|
||||
keybindingContext.setPendingChord(null)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
case 'none':
|
||||
// No match - let other handlers try
|
||||
break
|
||||
}
|
||||
},
|
||||
[action, context, handler, keybindingContext],
|
||||
)
|
||||
|
||||
useInput(handleInput, { isActive })
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle multiple keybindings in one hook (reduces useInput calls).
|
||||
*
|
||||
* Supports chord sequences. When a chord is started, the hook will
|
||||
* manage the pending state automatically.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useKeybindings({
|
||||
* 'chat:submit': () => handleSubmit(),
|
||||
* 'chat:cancel': () => handleCancel(),
|
||||
* }, { context: 'Chat' })
|
||||
* ```
|
||||
*/
|
||||
export function useKeybindings(
|
||||
// Handler returning `false` means "not consumed" — the event propagates
|
||||
// to later useInput/useKeybindings handlers. Useful for fall-through:
|
||||
// e.g. ScrollKeybindingHandler's scroll:line* returns false when the
|
||||
// ScrollBox content fits (scroll is a no-op), letting a child component's
|
||||
// handler take the wheel event for list navigation instead. Promise<void>
|
||||
// is allowed for fire-and-forget async handlers (the `!== false` check
|
||||
// only skips propagation for a sync `false`, not a pending Promise).
|
||||
handlers: Record<string, () => void | false | Promise<void>>,
|
||||
options: Options = {},
|
||||
): void {
|
||||
const { context = 'Global', isActive = true } = options
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
|
||||
// Register all handlers with the context for ChordInterceptor to invoke
|
||||
useEffect(() => {
|
||||
if (!keybindingContext || !isActive) return
|
||||
|
||||
const unregisterFns: Array<() => void> = []
|
||||
for (const [action, handler] of Object.entries(handlers)) {
|
||||
unregisterFns.push(
|
||||
keybindingContext.registerHandler({ action, context, handler }),
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const unregister of unregisterFns) {
|
||||
unregister()
|
||||
}
|
||||
}
|
||||
}, [context, handlers, keybindingContext, isActive])
|
||||
|
||||
const handleInput = useCallback(
|
||||
(input: string, key: Key, event: InputEvent) => {
|
||||
// If no keybinding context available, skip resolution
|
||||
if (!keybindingContext) return
|
||||
|
||||
// Build context list: registered active contexts + this context + Global
|
||||
// More specific contexts (registered ones) take precedence over Global
|
||||
const contextsToCheck: KeybindingContextName[] = [
|
||||
...keybindingContext.activeContexts,
|
||||
context,
|
||||
'Global',
|
||||
]
|
||||
// Deduplicate while preserving order (first occurrence wins for priority)
|
||||
const uniqueContexts = [...new Set(contextsToCheck)]
|
||||
|
||||
const result = keybindingContext.resolve(input, key, uniqueContexts)
|
||||
|
||||
switch (result.type) {
|
||||
case 'match':
|
||||
// Chord completed (if any) - clear pending state
|
||||
keybindingContext.setPendingChord(null)
|
||||
if (result.action in handlers) {
|
||||
const handler = handlers[result.action]
|
||||
if (handler && handler() !== false) {
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'chord_started':
|
||||
// User started a chord sequence - update pending state
|
||||
keybindingContext.setPendingChord(result.pending)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
case 'chord_cancelled':
|
||||
// Chord was cancelled (escape or invalid key)
|
||||
keybindingContext.setPendingChord(null)
|
||||
break
|
||||
case 'unbound':
|
||||
// Explicitly unbound - clear any pending chord
|
||||
keybindingContext.setPendingChord(null)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
case 'none':
|
||||
// No match - let other handlers try
|
||||
break
|
||||
}
|
||||
},
|
||||
[context, handlers, keybindingContext],
|
||||
)
|
||||
|
||||
useInput(handleInput, { isActive })
|
||||
}
|
||||
59
src/keybindings/useShortcutDisplay.ts
Normal file
59
src/keybindings/useShortcutDisplay.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import { useOptionalKeybindingContext } from './KeybindingContext.js'
|
||||
import type { KeybindingContextName } from './types.js'
|
||||
|
||||
// TODO(keybindings-migration): Remove fallback parameter after migration is complete
|
||||
// and we've confirmed no 'keybinding_fallback_used' events are being logged.
|
||||
// The fallback exists as a safety net during migration - if bindings fail to load
|
||||
// or an action isn't found, we fall back to hardcoded values. Once stable, callers
|
||||
// should be able to trust that getBindingDisplayText always returns a value for
|
||||
// known actions, and we can remove this defensive pattern.
|
||||
|
||||
/**
|
||||
* Hook to get the display text for a configured shortcut.
|
||||
* Returns the configured binding or a fallback if unavailable.
|
||||
*
|
||||
* @param action - The action name (e.g., 'app:toggleTranscript')
|
||||
* @param context - The keybinding context (e.g., 'Global')
|
||||
* @param fallback - Fallback text if keybinding context unavailable
|
||||
* @returns The configured shortcut display text
|
||||
*
|
||||
* @example
|
||||
* const expandShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o')
|
||||
* // Returns the user's configured binding, or 'ctrl+o' as default
|
||||
*/
|
||||
export function useShortcutDisplay(
|
||||
action: string,
|
||||
context: KeybindingContextName,
|
||||
fallback: string,
|
||||
): string {
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
const resolved = keybindingContext?.getDisplayText(action, context)
|
||||
const isFallback = resolved === undefined
|
||||
const reason = keybindingContext ? 'action_not_found' : 'no_context'
|
||||
|
||||
// Log fallback usage once per mount (not on every render) to avoid
|
||||
// flooding analytics with events from frequent re-renders.
|
||||
const hasLoggedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (isFallback && !hasLoggedRef.current) {
|
||||
hasLoggedRef.current = true
|
||||
logEvent('tengu_keybinding_fallback_used', {
|
||||
action:
|
||||
action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
context:
|
||||
context as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
fallback:
|
||||
fallback as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
reason:
|
||||
reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
}
|
||||
}, [isFallback, action, context, fallback, reason])
|
||||
|
||||
return isFallback ? fallback : resolved
|
||||
}
|
||||
498
src/keybindings/validate.ts
Normal file
498
src/keybindings/validate.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { plural } from '../utils/stringUtils.js'
|
||||
import { chordToString, parseChord, parseKeystroke } from './parser.js'
|
||||
import {
|
||||
getReservedShortcuts,
|
||||
normalizeKeyForComparison,
|
||||
} from './reservedShortcuts.js'
|
||||
import type {
|
||||
KeybindingBlock,
|
||||
KeybindingContextName,
|
||||
ParsedBinding,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Types of validation issues that can occur with keybindings.
|
||||
*/
|
||||
export type KeybindingWarningType =
|
||||
| 'parse_error'
|
||||
| 'duplicate'
|
||||
| 'reserved'
|
||||
| 'invalid_context'
|
||||
| 'invalid_action'
|
||||
|
||||
/**
|
||||
* A warning or error about a keybinding configuration issue.
|
||||
*/
|
||||
export type KeybindingWarning = {
|
||||
type: KeybindingWarningType
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
key?: string
|
||||
context?: string
|
||||
action?: string
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid context names for keybindings.
|
||||
* Must match KeybindingContextName in types.ts
|
||||
*/
|
||||
const VALID_CONTEXTS: KeybindingContextName[] = [
|
||||
'Global',
|
||||
'Chat',
|
||||
'Autocomplete',
|
||||
'Confirmation',
|
||||
'Help',
|
||||
'Transcript',
|
||||
'HistorySearch',
|
||||
'Task',
|
||||
'ThemePicker',
|
||||
'Settings',
|
||||
'Tabs',
|
||||
'Attachments',
|
||||
'Footer',
|
||||
'MessageSelector',
|
||||
'DiffDialog',
|
||||
'ModelPicker',
|
||||
'Select',
|
||||
'Plugin',
|
||||
]
|
||||
|
||||
/**
|
||||
* Type guard to check if a string is a valid context name.
|
||||
*/
|
||||
function isValidContext(value: string): value is KeybindingContextName {
|
||||
return (VALID_CONTEXTS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single keystroke string and return any parse errors.
|
||||
*/
|
||||
function validateKeystroke(keystroke: string): KeybindingWarning | null {
|
||||
const parts = keystroke.toLowerCase().split('+')
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (!trimmed) {
|
||||
return {
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `Empty key part in "${keystroke}"`,
|
||||
key: keystroke,
|
||||
suggestion: 'Remove extra "+" characters',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse and see if it fails
|
||||
const parsed = parseKeystroke(keystroke)
|
||||
if (
|
||||
!parsed.key &&
|
||||
!parsed.ctrl &&
|
||||
!parsed.alt &&
|
||||
!parsed.shift &&
|
||||
!parsed.meta
|
||||
) {
|
||||
return {
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `Could not parse keystroke "${keystroke}"`,
|
||||
key: keystroke,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a keybinding block from user config.
|
||||
*/
|
||||
function validateBlock(
|
||||
block: unknown,
|
||||
blockIndex: number,
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
|
||||
if (typeof block !== 'object' || block === null) {
|
||||
warnings.push({
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `Keybinding block ${blockIndex + 1} is not an object`,
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
const b = block as Record<string, unknown>
|
||||
|
||||
// Validate context - extract to narrowed variable for type safety
|
||||
const rawContext = b.context
|
||||
let contextName: string | undefined
|
||||
if (typeof rawContext !== 'string') {
|
||||
warnings.push({
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `Keybinding block ${blockIndex + 1} missing "context" field`,
|
||||
})
|
||||
} else if (!isValidContext(rawContext)) {
|
||||
warnings.push({
|
||||
type: 'invalid_context',
|
||||
severity: 'error',
|
||||
message: `Unknown context "${rawContext}"`,
|
||||
context: rawContext,
|
||||
suggestion: `Valid contexts: ${VALID_CONTEXTS.join(', ')}`,
|
||||
})
|
||||
} else {
|
||||
contextName = rawContext
|
||||
}
|
||||
|
||||
// Validate bindings
|
||||
if (typeof b.bindings !== 'object' || b.bindings === null) {
|
||||
warnings.push({
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `Keybinding block ${blockIndex + 1} missing "bindings" field`,
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
const bindings = b.bindings as Record<string, unknown>
|
||||
for (const [key, action] of Object.entries(bindings)) {
|
||||
// Validate key syntax
|
||||
const keyError = validateKeystroke(key)
|
||||
if (keyError) {
|
||||
keyError.context = contextName
|
||||
warnings.push(keyError)
|
||||
}
|
||||
|
||||
// Validate action
|
||||
if (action !== null && typeof action !== 'string') {
|
||||
warnings.push({
|
||||
type: 'invalid_action',
|
||||
severity: 'error',
|
||||
message: `Invalid action for "${key}": must be a string or null`,
|
||||
key,
|
||||
context: contextName,
|
||||
})
|
||||
} else if (typeof action === 'string' && action.startsWith('command:')) {
|
||||
// Validate command binding format
|
||||
if (!/^command:[a-zA-Z0-9:\-_]+$/.test(action)) {
|
||||
warnings.push({
|
||||
type: 'invalid_action',
|
||||
severity: 'warning',
|
||||
message: `Invalid command binding "${action}" for "${key}": command name may only contain alphanumeric characters, colons, hyphens, and underscores`,
|
||||
key,
|
||||
context: contextName,
|
||||
action,
|
||||
})
|
||||
}
|
||||
// Command bindings must be in Chat context
|
||||
if (contextName && contextName !== 'Chat') {
|
||||
warnings.push({
|
||||
type: 'invalid_action',
|
||||
severity: 'warning',
|
||||
message: `Command binding "${action}" must be in "Chat" context, not "${contextName}"`,
|
||||
key,
|
||||
context: contextName,
|
||||
action,
|
||||
suggestion: 'Move this binding to a block with "context": "Chat"',
|
||||
})
|
||||
}
|
||||
} else if (action === 'voice:pushToTalk') {
|
||||
// Hold detection needs OS auto-repeat. Bare letters print into the
|
||||
// input during warmup and the activation strip is best-effort —
|
||||
// space (default) or a modifier combo like meta+k avoid that.
|
||||
const ks = parseChord(key)[0]
|
||||
if (
|
||||
ks &&
|
||||
!ks.ctrl &&
|
||||
!ks.alt &&
|
||||
!ks.shift &&
|
||||
!ks.meta &&
|
||||
!ks.super &&
|
||||
/^[a-z]$/.test(ks.key)
|
||||
) {
|
||||
warnings.push({
|
||||
type: 'invalid_action',
|
||||
severity: 'warning',
|
||||
message: `Binding "${key}" to voice:pushToTalk prints into the input during warmup; use space or a modifier combo like meta+k`,
|
||||
key,
|
||||
context: contextName,
|
||||
action,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect duplicate keys within the same bindings block in a JSON string.
|
||||
* JSON.parse silently uses the last value for duplicate keys,
|
||||
* so we need to check the raw string to warn users.
|
||||
*
|
||||
* Only warns about duplicates within the same context's bindings object.
|
||||
* Duplicates across different contexts are allowed (e.g., "enter" in Chat
|
||||
* and "enter" in Confirmation).
|
||||
*/
|
||||
export function checkDuplicateKeysInJson(
|
||||
jsonString: string,
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
|
||||
// Find each "bindings" block and check for duplicates within it
|
||||
// Pattern: "bindings" : { ... }
|
||||
const bindingsBlockPattern =
|
||||
/"bindings"\s*:\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g
|
||||
|
||||
let blockMatch
|
||||
while ((blockMatch = bindingsBlockPattern.exec(jsonString)) !== null) {
|
||||
const blockContent = blockMatch[1]
|
||||
if (!blockContent) continue
|
||||
|
||||
// Find the context for this block by looking backwards
|
||||
const textBeforeBlock = jsonString.slice(0, blockMatch.index)
|
||||
const contextMatch = textBeforeBlock.match(
|
||||
/"context"\s*:\s*"([^"]+)"[^{]*$/,
|
||||
)
|
||||
const context = contextMatch?.[1] ?? 'unknown'
|
||||
|
||||
// Find all keys within this bindings block
|
||||
const keyPattern = /"([^"]+)"\s*:/g
|
||||
const keysByName = new Map<string, number>()
|
||||
|
||||
let keyMatch
|
||||
while ((keyMatch = keyPattern.exec(blockContent)) !== null) {
|
||||
const key = keyMatch[1]
|
||||
if (!key) continue
|
||||
|
||||
const count = (keysByName.get(key) ?? 0) + 1
|
||||
keysByName.set(key, count)
|
||||
|
||||
if (count === 2) {
|
||||
// Only warn on the second occurrence
|
||||
warnings.push({
|
||||
type: 'duplicate',
|
||||
severity: 'warning',
|
||||
message: `Duplicate key "${key}" in ${context} bindings`,
|
||||
key,
|
||||
context,
|
||||
suggestion: `This key appears multiple times in the same context. JSON uses the last value, earlier values are ignored.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user keybinding config and return all warnings.
|
||||
*/
|
||||
export function validateUserConfig(userBlocks: unknown): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
|
||||
if (!Array.isArray(userBlocks)) {
|
||||
warnings.push({
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: 'keybindings.json must contain an array',
|
||||
suggestion: 'Wrap your bindings in [ ]',
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
for (let i = 0; i < userBlocks.length; i++) {
|
||||
warnings.push(...validateBlock(userBlocks[i], i))
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for duplicate bindings within the same context.
|
||||
* Only checks user bindings (not default + user merged).
|
||||
*/
|
||||
export function checkDuplicates(
|
||||
blocks: KeybindingBlock[],
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
const seenByContext = new Map<string, Map<string, string>>()
|
||||
|
||||
for (const block of blocks) {
|
||||
const contextMap =
|
||||
seenByContext.get(block.context) ?? new Map<string, string>()
|
||||
seenByContext.set(block.context, contextMap)
|
||||
|
||||
for (const [key, action] of Object.entries(block.bindings)) {
|
||||
const normalizedKey = normalizeKeyForComparison(key)
|
||||
const existingAction = contextMap.get(normalizedKey)
|
||||
|
||||
if (existingAction && existingAction !== action) {
|
||||
warnings.push({
|
||||
type: 'duplicate',
|
||||
severity: 'warning',
|
||||
message: `Duplicate binding "${key}" in ${block.context} context`,
|
||||
key,
|
||||
context: block.context,
|
||||
action: action ?? 'null (unbind)',
|
||||
suggestion: `Previously bound to "${existingAction}". Only the last binding will be used.`,
|
||||
})
|
||||
}
|
||||
|
||||
contextMap.set(normalizedKey, action ?? 'null')
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for reserved shortcuts that may not work.
|
||||
*/
|
||||
export function checkReservedShortcuts(
|
||||
bindings: ParsedBinding[],
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
const reserved = getReservedShortcuts()
|
||||
|
||||
for (const binding of bindings) {
|
||||
const keyDisplay = chordToString(binding.chord)
|
||||
const normalizedKey = normalizeKeyForComparison(keyDisplay)
|
||||
|
||||
// Check against reserved shortcuts
|
||||
for (const res of reserved) {
|
||||
if (normalizeKeyForComparison(res.key) === normalizedKey) {
|
||||
warnings.push({
|
||||
type: 'reserved',
|
||||
severity: res.severity,
|
||||
message: `"${keyDisplay}" may not work: ${res.reason}`,
|
||||
key: keyDisplay,
|
||||
context: binding.context,
|
||||
action: binding.action ?? undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user blocks into bindings for validation.
|
||||
* This is separate from the main parser to avoid importing it.
|
||||
*/
|
||||
function getUserBindingsForValidation(
|
||||
userBlocks: KeybindingBlock[],
|
||||
): ParsedBinding[] {
|
||||
const bindings: ParsedBinding[] = []
|
||||
for (const block of userBlocks) {
|
||||
for (const [key, action] of Object.entries(block.bindings)) {
|
||||
const chord = key.split(' ').map(k => parseKeystroke(k))
|
||||
bindings.push({
|
||||
chord,
|
||||
action,
|
||||
context: block.context,
|
||||
})
|
||||
}
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all validations and return combined warnings.
|
||||
*/
|
||||
export function validateBindings(
|
||||
userBlocks: unknown,
|
||||
_parsedBindings: ParsedBinding[],
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
|
||||
// Validate user config structure
|
||||
warnings.push(...validateUserConfig(userBlocks))
|
||||
|
||||
// Check for duplicates in user config
|
||||
if (isKeybindingBlockArray(userBlocks)) {
|
||||
warnings.push(...checkDuplicates(userBlocks))
|
||||
|
||||
// Check for reserved/conflicting shortcuts - only check USER bindings
|
||||
const userBindings = getUserBindingsForValidation(userBlocks)
|
||||
warnings.push(...checkReservedShortcuts(userBindings))
|
||||
}
|
||||
|
||||
// Deduplicate warnings (same key+context+type)
|
||||
const seen = new Set<string>()
|
||||
return warnings.filter(w => {
|
||||
const key = `${w.type}:${w.key}:${w.context}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a warning for display to the user.
|
||||
*/
|
||||
export function formatWarning(warning: KeybindingWarning): string {
|
||||
const icon = warning.severity === 'error' ? '✗' : '⚠'
|
||||
let msg = `${icon} Keybinding ${warning.severity}: ${warning.message}`
|
||||
|
||||
if (warning.suggestion) {
|
||||
msg += `\n ${warning.suggestion}`
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
/**
|
||||
* Format multiple warnings for display.
|
||||
*/
|
||||
export function formatWarnings(warnings: KeybindingWarning[]): string {
|
||||
if (warnings.length === 0) return ''
|
||||
|
||||
const errors = warnings.filter(w => w.severity === 'error')
|
||||
const warns = warnings.filter(w => w.severity === 'warning')
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
if (errors.length > 0) {
|
||||
lines.push(
|
||||
`Found ${errors.length} keybinding ${plural(errors.length, 'error')}:`,
|
||||
)
|
||||
for (const e of errors) {
|
||||
lines.push(formatWarning(e))
|
||||
}
|
||||
}
|
||||
|
||||
if (warns.length > 0) {
|
||||
if (lines.length > 0) lines.push('')
|
||||
lines.push(
|
||||
`Found ${warns.length} keybinding ${plural(warns.length, 'warning')}:`,
|
||||
)
|
||||
for (const w of warns) {
|
||||
lines.push(formatWarning(w))
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
Reference in New Issue
Block a user