init: add source code from src.zip

This commit is contained in:
sigridjineth
2026-03-31 01:55:58 -07:00
commit f5a40b86de
1902 changed files with 513237 additions and 0 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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',
},
},
]

View 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
View 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
View 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
}

View 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
View 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
View 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>
>

View 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
}

View 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'
}

View 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 })
}

View 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
View 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')
}