first commit

This commit is contained in:
H
2026-04-03 13:01:19 +08:00
commit 538eced414
2575 changed files with 645911 additions and 0 deletions

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'
/**
* 匹配当前 Claude Code 行为的默认键绑定。
* 这些首先加载,然后用户 keybindings.json 覆盖它们。
*/
// 特定平台的图像粘贴快捷键:
// - Windowsalt+vctrl+v 是系统粘贴)
// - 其他平台ctrl+v
const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v'
// 修饰符-only 和弦(如 shift+tab在没有 VT 模式的 Windows Terminal 上可能失败
// 参见https://github.com/microsoft/terminal/issues/879#issuecomment-618801651
// Node 在 24.2.0 / 22.17.0 启用 VT 模式https://github.com/nodejs/node/pull/58358
// Bun 在 1.2.23 启用 VT 模式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'))
// 特定平台的模式循环快捷键:
// - 没有 VT 模式的 Windowsmeta+mshift+tab 不能可靠工作)
// - 其他平台shift+tab
const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m'
export const DEFAULT_BINDINGS: KeybindingBlock[] = [
{
context: 'Global',
bindings: {
// ctrl+c 和 ctrl+d 使用特殊的时间-based 双击处理。
// 它们在此定义以便解析器可以找到,但用户
// 不能重新绑定 - validate.ts 中的 reservedShortcuts.ts
// 会在用户尝试覆盖这些键时显示错误。
'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',
// 文件导航。cmd+ 绑定仅在 kitty 协议终端上触发;
// ctrl+shift 是可移植的回退。
...(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 前缀避免与 readline 编辑键冲突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',
// 编辑快捷键(此处定义,迁移进行中)
// 撤消有两个绑定以支持不同的终端行为:
// - ctrl+_ 对于传统终端(发送 \x1f 控制字符)
// - ctrl+shift+- 对于 Kitty 协议(发送带修饰符的物理键)
'ctrl+_': 'chat:undo',
'ctrl+shift+-': 'chat:undo',
// ctrl+x ctrl+e 是 readline 原生的编辑和执行命令绑定。
'ctrl+x ctrl+e': 'chat:externalEditor',
'ctrl+g': 'chat:externalEditor',
'ctrl+s': 'chat:stash',
// 图像粘贴快捷键(特定平台密钥在上面定义)
[IMAGE_PASTE_KEY]: 'chat:imagePaste',
...(feature('MESSAGE_ACTIONS')
? { 'shift+up': 'chat:messageActions' as const }
: {}),
// 语音激活(按住说话)。注册以便 getShortcutDisplay
// 找到它而不触发回退分析日志。要重新绑定,
// 添加 voice:pushToTalk 条目(后者获胜);要禁用,使用 /voice
// — null 取消绑定空间会击中 useKeybinding.ts 中预先存在的陷阱
// 其中 'unbound' 吞下事件(空格对输入无效)。
...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {}),
},
},
{
context: 'Autocomplete',
bindings: {
tab: 'autocomplete:accept',
escape: 'autocomplete:dismiss',
up: 'autocomplete:previous',
down: 'autocomplete:next',
},
},
{
context: 'Settings',
bindings: {
// 设置菜单仅使用 escape不是 'n')来关闭
escape: 'confirm:no',
// 配置面板列表导航(重用 Select 操作)
up: 'select:previous',
down: 'select:next',
k: 'select:previous',
j: 'select:next',
'ctrl+p': 'select:previous',
'ctrl+n': 'select:next',
// 切换/激活选定的设置(仅空格 — enter 保存并关闭)
space: 'select:accept',
// 保存并关闭配置面板
enter: 'settings:close',
// 进入搜索模式
'/': 'settings:search',
// 重试加载使用数据(仅在错误时活动)
r: 'settings:retry',
},
},
{
context: 'Confirmation',
bindings: {
y: 'confirm:yes',
n: 'confirm:no',
enter: 'confirm:yes',
escape: 'confirm:no',
// 带列表的对话框导航
up: 'confirm:previous',
down: 'confirm:next',
tab: 'confirm:nextField',
space: 'confirm:toggle',
// 循环模式(用于文件权限对话框和团队对话框)
'shift+tab': 'confirm:cycleMode',
// 在权限对话框中切换权限说明
'ctrl+e': 'confirm:toggleExplanation',
// 切换权限调试信息
'ctrl+d': 'permission:toggleDebug',
},
},
{
context: 'Tabs',
bindings: {
// 标签循环导航
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 — 分页器约定less、tmux 复制模式。Transcript 是一个模态
// 阅读视图,没有提示,所以 q 作为文字字符没有所有者。
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: {
// 在前台运行后台任务bash 命令、代理)
// 在 tmux 中,用户必须按两次 ctrl+btmux 前缀转义)
'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',
// 选择复制。ctrl+shift+c 是标准终端复制。
// cmd+c 仅在使用 kitty 键盘
// 协议kitty/WezTerm/ghostty/iTerm2的终端上触发其中 super
// 修饰符实际到达 pty — 在其他地方无效。
// Esc-to-clear 和上下文 ctrl+c 通过原始
// useInput 处理,因此它们可以有条件地传播。
'ctrl+shift+c': 'selection:copy',
'cmd+c': 'selection:copy',
},
},
{
context: 'Help',
bindings: {
escape: 'help:dismiss',
},
},
// 附件导航(选择对话框图像附件)
{
context: 'Attachments',
bindings: {
right: 'attachments:next',
left: 'attachments:previous',
backspace: 'attachments:remove',
delete: 'attachments:remove',
down: 'attachments:exit',
escape: 'attachments:exit',
},
},
// 页脚指示器导航(任务、团队、差异、循环)
{
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',
},
},
// 消息选择器(倒带对话框)导航
{
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 在光标活动时卸载 — 没有键冲突。
...(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 = macOS 上的 cmdkitty 键盘协议的 super — 绑定两者。
'meta+up': 'messageActions:top' as const,
'meta+down': 'messageActions:bottom' as const,
'super+up': 'messageActions:top' as const,
'super+down': 'messageActions:bottom' as const,
// 当存在时,鼠标选择扩展 shift+arrowScrollKeybindingHandler:573
// 正确的分层 UXesc 清除选择,然后 shift+↑ 跳转。
'shift+up': 'messageActions:prevUser' as const,
'shift+down': 'messageActions:nextUser' as const,
escape: 'messageActions:escape' as const,
'ctrl+c': 'messageActions:ctrlc' as const,
// 镜像 MESSAGE_ACTIONS。不是导入 — 会将 React/ink 拉入此配置模块。
enter: 'messageActions:enter' as const,
c: 'messageActions:c' as const,
p: 'messageActions:p' as const,
},
},
]
: []),
// 差异对话框导航
{
context: 'DiffDialog',
bindings: {
escape: 'diff:dismiss',
left: 'diff:previousSource',
right: 'diff:nextSource',
up: 'diff:previousFile',
down: 'diff:nextFile',
enter: 'diff:viewDetails',
// 注意diff:back 在详细模式下由左箭头处理
},
},
// 模型选择器工作循环(仅限 ant
{
context: 'ModelPicker',
bindings: {
left: 'modelPicker:decreaseEffort',
right: 'modelPicker:increaseEffort',
},
},
// 选择组件导航(用于 /model、/resume、权限提示等
{
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',
},
},
// 插件对话框操作(管理、浏览、发现插件)
// 导航select:*)使用上面的 Select 上下文
{
context: 'Plugin',
bindings: {
space: 'plugin:toggle',
i: 'plugin:install',
},
},
]

View File

@@ -0,0 +1,472 @@
/**
* 用户键绑定配置加载器,支持热重载。
*
* 从 ~/.claude/keybindings.json 加载键绑定并监视
* 变化以自动重新加载。
*
* 注意:用户键绑定自定义目前仅适用于
* Anthropic 员工USER_TYPE === 'ant')。外部用户始终
* 使用默认绑定。
*/
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'
/**
* 检查键绑定自定义是否启用。
*
* 如果 tengu_keybinding_customization_release GrowthBook 开关启用则返回 true。
*
* 此函数被导出以便代码库的其他部分(例如 /doctor
* 可以一致地检查相同的条件。
*/
export function isKeybindingCustomizationEnabled(): boolean {
return getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_keybinding_customization_release',
false,
)
}
/**
* 等待文件写入稳定的毫秒数。
*/
const FILE_STABILITY_THRESHOLD_MS = 500
/**
* 检查文件稳定性的轮询间隔。
*/
const FILE_STABILITY_POLL_INTERVAL_MS = 200
/**
* 加载键绑定的结果,包括任何验证警告。
*/
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]>()
/**
* 跟踪上次记录自定义键绑定加载事件的日期YYYY-MM-DD
* 用于确保我们每天最多触发一次事件。
*/
let lastCustomBindingsLogDate: string | null = null
/**
* 当自定义键绑定加载时记录遥测事件,每天最多一次。
* 这使我们能够估计自定义键绑定的用户百分比。
*/
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,
})
}
/**
* 类型守卫,检查对象是否是有效的 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
)
}
/**
* 类型守卫,检查数组是否仅包含有效的 KeybindingBlock。
*/
function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] {
return Array.isArray(arr) && arr.every(isKeybindingBlock)
}
/**
* 获取用户键绑定文件的路径。
*/
export function getKeybindingsPath(): string {
return join(getClaudeConfigHomeDir(), 'keybindings.json')
}
/**
* 解析默认绑定(缓存以提高性能)。
*/
function getDefaultParsedBindings(): ParsedBinding[] {
return parseBindings(DEFAULT_BINDINGS)
}
/**
* 从用户配置文件加载并解析键绑定。
* 返回合并的默认 + 用户绑定以及验证警告。
*
* 对于外部用户,始终仅返回默认绑定。
* 用户自定义目前仅限于 Anthropic 员工。
*/
export async function loadKeybindings(): Promise<KeybindingsLoadResult> {
const defaultBindings = getDefaultParsedBindings()
// 跳过外部用户加载用户配置
if (!isKeybindingCustomizationEnabled()) {
return { bindings: defaultBindings, warnings: [] }
}
const userPath = getKeybindingsPath()
try {
const content = await readFile(userPath, 'utf-8')
const parsed: unknown = jsonParse(content)
// 从对象包装格式中提取绑定数组:{ "bindings": [...] }
let userBlocks: unknown
if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) {
userBlocks = (parsed as { bindings: unknown }).bindings
} else {
// 格式无效 - 缺少 bindings 属性
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,
},
],
}
}
// 验证结构 - bindings 必须是有效键绑定块的数组
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}`,
)
// 用户绑定在默认绑定之后,所以它们会覆盖
const mergedBindings = [...defaultBindings, ...userParsed]
logCustomBindingsLoadedOncePerDay(userParsed.length)
// 对用户配置运行验证
// 首先检查原始 JSON 中的重复键JSON.parse 会静默丢弃较早的值)
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) {
// 文件不存在 - 使用默认值(用户可以运行 /keybindings 创建)
if (isENOENT(error)) {
return { bindings: defaultBindings, warnings: [] }
}
// 其他错误 - 记录并返回带警告的默认值
logForDebugging(
`[keybindings] Error loading ${userPath}: ${errorMessage(error)}`,
)
return {
bindings: defaultBindings,
warnings: [
{
type: 'parse_error',
severity: 'error',
message: `Failed to parse keybindings.json: ${errorMessage(error)}`,
},
],
}
}
}
/**
* 同步加载键绑定(用于初始渲染)。
* 如果有缓存值则使用缓存。
*/
export function loadKeybindingsSync(): ParsedBinding[] {
if (cachedBindings) {
return cachedBindings
}
const result = loadKeybindingsSyncWithWarnings()
return result.bindings
}
/**
* 同步加载带验证警告的键绑定。
* 如果有缓存值则使用缓存。
*
* 对于外部用户,始终仅返回默认绑定。
* 用户自定义目前仅限于 Anthropic 员工。
*/
export function loadKeybindingsSyncWithWarnings(): KeybindingsLoadResult {
if (cachedBindings) {
return { bindings: cachedBindings, warnings: cachedWarnings }
}
const defaultBindings = getDefaultParsedBindings()
// 跳过外部用户加载用户配置
if (!isKeybindingCustomizationEnabled()) {
cachedBindings = defaultBindings
cachedWarnings = []
return { bindings: cachedBindings, warnings: cachedWarnings }
}
const userPath = getKeybindingsPath()
try {
// 同步 IO从同步上下文调用React useState 初始化器)
const content = readFileSync(userPath, 'utf-8')
const parsed: unknown = jsonParse(content)
// 从对象包装格式中提取绑定数组:{ "bindings": [...] }
let userBlocks: unknown
if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) {
userBlocks = (parsed as { bindings: unknown }).bindings
} else {
// 格式无效 - 缺少 bindings 属性
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 }
}
// 验证结构 - bindings 必须是有效键绑定块的数组
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)
// 运行验证 - 首先检查原始 JSON 中的重复键
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 {
// 文件不存在或出错 - 使用默认值(用户可以运行 /keybindings 创建)
cachedBindings = defaultBindings
cachedWarnings = []
return { bindings: cachedBindings, warnings: cachedWarnings }
}
}
/**
* 初始化 keybindings.json 的文件监视。
* 在应用启动时调用一次。
*
* 对于外部用户,这是空操作,因为用户自定义已禁用。
*/
export async function initializeKeybindingWatcher(): Promise<void> {
if (initialized || disposed) return
// 跳过外部用户的文件监视
if (!isKeybindingCustomizationEnabled()) {
logForDebugging(
'[keybindings] Skipping file watcher - user customization disabled',
)
return
}
const userPath = getKeybindingsPath()
const watchDir = dirname(userPath)
// 仅在父目录存在时监视
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
}
// 仅在我们确认可以监视后才设置 initialized
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)
// 注册清理
registerCleanup(async () => disposeKeybindingWatcher())
}
/**
* 清理文件监视器。
*/
export function disposeKeybindingWatcher(): void {
disposed = true
if (watcher) {
void watcher.close()
watcher = null
}
keybindingsChanged.clear()
}
/**
* 订阅键绑定更改。
* 监听器在文件更改时接收新的解析绑定。
*/
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
// 用完整结果通知所有监听器
keybindingsChanged.emit(result)
} catch (error) {
logForDebugging(`[keybindings] Error reloading: ${errorMessage(error)}`)
}
}
function handleDelete(path: string): void {
logForDebugging(`[keybindings] Detected deletion of ${path}`)
// 删除文件时重置为默认值
const defaultBindings = getDefaultParsedBindings()
cachedBindings = defaultBindings
cachedWarnings = []
keybindingsChanged.emit({ bindings: defaultBindings, warnings: [] })
}
/**
* 获取缓存的键绑定警告。
* 如果没有警告或绑定尚未加载,则返回空数组。
*/
export function getCachedKeybindingWarnings(): KeybindingWarning[] {
return cachedWarnings
}
/**
* 重置内部状态以进行测试。
*/
export function resetKeybindingLoaderForTesting(): void {
initialized = false
disposed = false
cachedBindings = null
cachedWarnings = []
lastCustomBindingsLogDate = null
if (watcher) {
void watcher.close()
watcher = null
}
keybindingsChanged.clear()
}

View File

@@ -0,0 +1,117 @@
import type { Key } from '../ink.js'
import type { ParsedBinding, ParsedKeystroke } from './types.js'
/**
* Ink Key 类型中我们关心的修饰符键。
* 注意:`fn` 从 Key 中故意排除,因为它很少使用且
* 在终端应用程序中通常不可配置。
*/
type InkModifiers = Pick<Key, 'ctrl' | 'shift' | 'meta' | 'super'>
/**
* 从 Ink Key 对象提取修饰符。
* 此函数确保我们明确提取我们关心的修饰符。
*/
function getInkModifiers(key: Key): InkModifiers {
return {
ctrl: key.ctrl,
shift: key.shift,
meta: key.meta,
super: key.super,
}
}
/**
* 从 Ink 的 Key + input 提取规范化键名。
* 将 Ink 的布尔标志key.escape、key.return 等)映射到与我们的 ParsedKeystroke.key 格式匹配的字符串名称。
*/
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
}
/**
* 检查所有修饰符是否在 Ink Key 和 ParsedKeystroke 之间匹配。
*
* Alt 和 MetaInk 历史上为 Alt/Option 设置 `key.meta`。配置中的 `meta`
* 修饰符被视为 `alt` 的别名 — 当 `key.meta` 为 true 时两者都匹配。
*
* Super (Cmd/Win):与 alt/meta 不同。仅通过 kitty
* 键盘协议的 支持终端到达。`cmd`/`super` 绑定将在不发送它的终端上永远不会触发。
*/
function modifiersMatch(
inkMods: InkModifiers,
target: ParsedKeystroke,
): boolean {
// 检查 ctrl 修饰符
if (inkMods.ctrl !== target.ctrl) return false
// 检查 shift 修饰符
if (inkMods.shift !== target.shift) return false
// Alt 和 meta 都映射到 Ink 中的 key.meta终端限制
// 所以我们检查 EITHER alt OR meta 是否在目标中需要
const targetNeedsMeta = target.alt || target.meta
if (inkMods.meta !== targetNeedsMeta) return false
// Super (cmd/win) 是与 alt/meta 不同的修饰符
if (inkMods.super !== target.super) return false
return true
}
/**
* 检查 ParsedKeystroke 是否与给定的 Ink input + Key 匹配。
*
* 显示文本将显示平台适当的名称macOS 上的 opt其他地方 alt
*/
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)
// 怪癖:当按下 escape 时 Ink 设置 key.meta=true见 input-event.ts
// 这是终端中转义序列工作方式的遗留行为。
// 我们需要在匹配 escape 键本身时忽略 meta 修饰符,
// 否则像 "escape"(无修饰符)这样的绑定永远不会匹配。
if (key.escape) {
return modifiersMatch({ ...inkMods, meta: false }, target)
}
return modifiersMatch(inkMods, target)
}
/**
* 检查 Ink 的 Key + input 是否匹配解析绑定的第一个按键。
* 仅适用于单按键绑定(第 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)
}

View File

@@ -0,0 +1,202 @@
import type {
Chord,
KeybindingBlock,
ParsedBinding,
ParsedKeystroke,
} from './types.js'
/**
* 将如 "ctrl+shift+k" 这样的按键字符串解析为 ParsedKeystroke。
* 支持各种修饰符别名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
}
/**
* 将如 "ctrl+k ctrl+s" 这样的和弦字符串解析为 ParsedKeystroke 数组。
*/
export function parseChord(input: string): Chord {
// 单独的空格字符是空格键绑定,不是分隔符
if (input === ' ') return [parseKeystroke('space')]
return input.trim().split(/\s+/).map(parseKeystroke)
}
/**
* 将 ParsedKeystroke 转换为其规范字符串表示以进行显示。
*/
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')
// 使用可读名称显示
const displayKey = keyToDisplayName(ks.key)
parts.push(displayKey)
return parts.join('+')
}
/**
* 将内部键名映射到可读的显示名称。
*/
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
}
}
/**
* 将 Chord 转换为其规范字符串表示以进行显示。
*/
export function chordToString(chord: Chord): string {
return chord.map(keystrokeToString).join(' ')
}
/**
* 显示平台类型 - 我们关心的平台子集,用于显示。
* WSL 和 unknown 被视为 linux 用于显示目的。
*/
type DisplayPlatform = 'macos' | 'windows' | 'linux' | 'wsl' | 'unknown'
/**
* 将 ParsedKeystroke 转换为平台适当的显示字符串。
* 在 macOS 上使用 "opt" 表示 alt其他地方使用 "alt"。
*/
export function keystrokeToDisplayString(
ks: ParsedKeystroke,
platform: DisplayPlatform = 'linux',
): string {
const parts: string[] = []
if (ks.ctrl) parts.push('ctrl')
// Alt/meta 在终端中是等价的,显示平台适当的名称
if (ks.alt || ks.meta) {
// 只有 macOS 使用 "opt",所有其他平台使用 "alt"
parts.push(platform === 'macos' ? 'opt' : 'alt')
}
if (ks.shift) parts.push('shift')
if (ks.super) {
parts.push(platform === 'macos' ? 'cmd' : 'super')
}
// 使用可读名称显示
const displayKey = keyToDisplayName(ks.key)
parts.push(displayKey)
return parts.join('+')
}
/**
* 将 Chord 转换为平台适当的显示字符串。
*/
export function chordToDisplayString(
chord: Chord,
platform: DisplayPlatform = 'linux',
): string {
return chord.map(ks => keystrokeToDisplayString(ks, platform)).join(' ')
}
/**
* 解析键绑定块(来自 JSON 配置)为 ParsedBinding 平面列表。
*/
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'
/**
* 通常被操作系统、终端或 shell 拦截的快捷键,
* 可能永远不会到达应用程序。
*/
export type ReservedShortcut = {
key: string
reason: string
severity: 'error' | 'warning'
}
/**
* 无法重新绑定的快捷键 - 它们在 Claude Code 中是硬编码的。
*/
export const NON_REBINDABLE: ReservedShortcut[] = [
{
key: 'ctrl+c',
reason: '无法重新绑定 - 用于中断/退出(硬编码)',
severity: 'error',
},
{
key: 'ctrl+d',
reason: '无法重新绑定 - 用于退出(硬编码)',
severity: 'error',
},
{
key: 'ctrl+m',
reason:
'无法重新绑定 - 在终端中与 Enter 相同(都发送 CR',
severity: 'error',
},
]
/**
* 被终端/操作系统拦截的终端控制快捷键。
* 这些可能永远不会到达应用程序。
*
* 注意ctrl+sXOFF和 ctrl+qXON不包含在此处因为
* - 大多数现代终端默认禁用流控制
* - 我们使用 ctrl+s 作为 stash 功能
*/
export const TERMINAL_RESERVED: ReservedShortcut[] = [
{
key: 'ctrl+z',
reason: 'Unix 进程挂起SIGTSTP',
severity: 'warning',
},
{
key: 'ctrl+\\',
reason: '终端退出信号SIGQUIT',
severity: 'error',
},
]
/**
* macOS 特定的快捷键,操作系统会拦截。
*/
export const MACOS_RESERVED: ReservedShortcut[] = [
{ key: 'cmd+c', reason: 'macOS 系统复制', severity: 'error' },
{ key: 'cmd+v', reason: 'macOS 系统粘贴', severity: 'error' },
{ key: 'cmd+x', reason: 'macOS 系统剪切', severity: 'error' },
{ key: 'cmd+q', reason: 'macOS 退出应用程序', severity: 'error' },
{ key: 'cmd+w', reason: 'macOS 关闭窗口/标签页', severity: 'error' },
{ key: 'cmd+tab', reason: 'macOS 应用切换器', severity: 'error' },
{ key: 'cmd+space', reason: 'macOS Spotlight', severity: 'error' },
]
/**
* 获取当前平台的所有保留快捷键。
* 包括不可重新绑定的快捷键和终端保留的快捷键。
*/
export function getReservedShortcuts(): ReservedShortcut[] {
const platform = getPlatform()
// 不可重新绑定的快捷键优先(最高优先级)
const reserved = [...NON_REBINDABLE, ...TERMINAL_RESERVED]
if (platform === 'macos') {
reserved.push(...MACOS_RESERVED)
}
return reserved
}
/**
* 规范化按键字符串以便比较(小写、排序修饰符)。
* 和弦(以空格分隔的步骤如 "ctrl+x ctrl+b")被规范化
* 每个步骤 - 首先在 '+' 上分割会损坏 "x ctrl" 为 mainKey
* 被下一个步骤覆盖,将和弦折叠为其最后一个键。
*/
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)
) {
// 规范化修饰符名称
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('+')
}

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' }
/**
* 将键输入解析为操作。
* 纯函数 — 无状态,无副作用,只是匹配逻辑。
*
* @param input - 来自 Ink 的字符输入
* @param key - 带修饰符标志的 Ink Key 对象
* @param activeContexts - 当前活动上下文的数组(例如 ['Chat', 'Global']
* @param bindings - 要搜索的所有解析绑定
* @returns 解析结果
*/
export function resolveKey(
input: string,
key: Key,
activeContexts: KeybindingContextName[],
bindings: ParsedBinding[],
): ResolveResult {
// 查找匹配的绑定(用户覆盖,后者获胜)
let match: ParsedBinding | undefined
const ctxSet = new Set(activeContexts)
for (const binding of bindings) {
// 阶段 1仅单按键绑定
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 }
}
/**
* 从绑定获取操作的可显示文本(例如 "ctrl+t" 表示 "app:toggleTodos")。
* 逆序搜索以便用户覆盖优先。
*/
export function getBindingDisplayText(
action: string,
context: KeybindingContextName,
bindings: ParsedBinding[],
): string | undefined {
// 在此上下文中查找此操作的最后一个绑定
const binding = bindings.findLast(
b => b.action === action && b.context === context,
)
return binding ? chordToString(binding.chord) : undefined
}
/**
* 从 Ink 的 input/key 构建 ParsedKeystroke。
*/
function buildKeystroke(input: string, key: Key): ParsedKeystroke | null {
const keyName = getKeyName(input, key)
if (!keyName) return null
// 怪癖:按下 escape 时 Ink 设置 key.meta=true见 input-event.ts
// 这是遗留的终端行为 — 我们不应该将其记录为修饰符
// 对于 escape 键本身,否则和弦匹配会失败。
const effectiveMeta = key.escape ? false : key.meta
return {
key: keyName,
ctrl: key.ctrl,
alt: effectiveMeta,
shift: key.shift,
meta: effectiveMeta,
super: key.super,
}
}
/**
* 比较两个 ParsedKeystroke 是否相等。将 alt/meta 折叠为
* 一个逻辑修饰符 — 遗留终端无法区分(见
* match.ts modifiersMatch因此 "alt+k" 和 "meta+k" 是相同的键。
* Super (cmd/win) 是不同的 — 仅通过 kitty 键盘协议到达。
*/
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
)
}
/**
* 检查和弦前缀是否匹配绑定和弦的开头。
*/
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
}
/**
* 检查完整和弦是否匹配绑定的和弦。
*/
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
}
/**
* 带和弦状态支持解析键。
*
* 此函数处理如 "ctrl+k ctrl+s" 的多按键和弦绑定。
*
* @param input - 来自 Ink 的字符输入
* @param key - 带修饰符标志的 Ink Key 对象
* @param activeContexts - 当前活动上下文的数组
* @param bindings - 所有解析的绑定
* @param pending - 当前和弦状态(如果不处于和弦中则为 null
* @returns 带和弦状态的解析结果
*/
export function resolveKeyWithChordState(
input: string,
key: Key,
activeContexts: KeybindingContextName[],
bindings: ParsedBinding[],
pending: ParsedKeystroke[] | null,
): ChordResolveResult {
// 在 escape 上取消和弦
if (key.escape && pending !== null) {
return { type: 'chord_cancelled' }
}
// 构建当前按键
const currentKeystroke = buildKeystroke(input, key)
if (!currentKeystroke) {
if (pending !== null) {
return { type: 'chord_cancelled' }
}
return { type: 'none' }
}
// 构建要测试的完整和弦序列
const testChord = pending
? [...pending, currentKeystroke]
: [currentKeystroke]
// 按活动上下文过滤绑定Set 查找O(n) 而不是 O(n·m)
const ctxSet = new Set(activeContexts)
const contextBindings = bindings.filter(b => ctxSet.has(b.context))
// 检查这是否可能是更长和弦的前缀。按和弦
// 字符串分组,以便后面的 null 覆盖覆盖它取消绑定的默认 —
// 否则 null 取消绑定 `ctrl+x ctrl+k` 仍使 `ctrl+x` 进入
// 和弦等待,单键绑定在前缀上永远不会触发。
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 (hasLongerChords) {
return { type: 'chord_started', pending: testChord }
}
// 检查精确匹配(后者获胜)
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 }
}
// 没有匹配且没有潜在的长和弦
if (pending !== null) {
return { type: 'chord_cancelled' }
}
return { type: 'none' }
}

View File

@@ -0,0 +1,236 @@
/**
* keybindings.json 配置的 Zod 模式。
* 用于验证和 JSON 模式生成。
*/
import { z } from 'zod/v4'
import { lazySchema } from '../utils/lazySchema.js'
/**
* 有效的键绑定上下文名称。
*/
export const KEYBINDING_CONTEXTS = [
'Global',
'Chat',
'Autocomplete',
'Confirmation',
'Help',
'Transcript',
'HistorySearch',
'Task',
'ThemePicker',
'Settings',
'Tabs',
// 键绑定迁移的新上下文
'Attachments',
'Footer',
'MessageSelector',
'DiffDialog',
'ModelPicker',
'Select',
'Plugin',
] as const
/**
* 每个键绑定上下文的可读描述。
*/
export const KEYBINDING_CONTEXT_DESCRIPTIONS: Record<
(typeof KEYBINDING_CONTEXTS)[number],
string
> = {
Global: '无论焦点在哪里,活动无处不在',
Chat: '当聊天输入获得焦点时',
Autocomplete: '当自动完成菜单可见时',
Confirmation: '当显示确认/权限对话框时',
Help: '当帮助覆盖层打开时',
Transcript: '当查看转录时',
HistorySearch: '当搜索命令历史时ctrl+r',
Task: '当任务/代理在前台运行时',
ThemePicker: '当主题选择器打开时',
Settings: '当设置菜单打开时',
Tabs: '当标签导航活动时',
Attachments: '当在选择对话框中导航图像附件时',
Footer: '当页脚指示器获得焦点时',
MessageSelector: '当消息选择器(倒带)打开时',
DiffDialog: '当差异对话框打开时',
ModelPicker: '当模型选择器打开时',
Select: '当选择/列表组件获得焦点时',
Plugin: '当插件对话框打开时',
}
/**
* 所有有效的键绑定操作标识符。
*/
export const KEYBINDING_ACTIONS = [
// 应用级操作(全局上下文)
'app:interrupt',
'app:exit',
'app:toggleTodos',
'app:toggleTranscript',
'app:toggleBrief',
'app:toggleTeammatePreview',
'app:toggleTerminal',
'app:redraw',
'app:globalSearch',
'app:quickOpen',
// 历史导航
'history:search',
'history:previous',
'history:next',
// 聊天输入操作
'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:accept',
'autocomplete:dismiss',
'autocomplete:previous',
'autocomplete:next',
// 确认对话框操作
'confirm:yes',
'confirm:no',
'confirm:previous',
'confirm:next',
'confirm:nextField',
'confirm:previousField',
'confirm:cycleMode',
'confirm:toggle',
'confirm:toggleExplanation',
// 标签导航操作
'tabs:next',
'tabs:previous',
// 转录查看器操作
'transcript:toggleShowAll',
'transcript:exit',
// 历史搜索操作
'historySearch:next',
'historySearch:accept',
'historySearch:cancel',
'historySearch:execute',
// 任务/代理操作
'task:background',
// 主题选择器操作
'theme:toggleSyntaxHighlighting',
// 帮助菜单操作
'help:dismiss',
// 附件导航(选择对话框图像附件)
'attachments:next',
'attachments:previous',
'attachments:remove',
'attachments:exit',
// 页脚指示器操作
'footer:up',
'footer:down',
'footer:next',
'footer:previous',
'footer:openSelected',
'footer:clearSelection',
'footer:close',
// 消息选择器(倒带)操作
'messageSelector:up',
'messageSelector:down',
'messageSelector:top',
'messageSelector:bottom',
'messageSelector:select',
// 差异对话框操作
'diff:dismiss',
'diff:previousSource',
'diff:nextSource',
'diff:back',
'diff:viewDetails',
'diff:previousFile',
'diff:nextFile',
// 模型选择器操作(仅限 ant
'modelPicker:decreaseEffort',
'modelPicker:increaseEffort',
// 选择组件操作(与 confirm: 区分以避免冲突)
'select:next',
'select:previous',
'select:accept',
'select:cancel',
// 插件对话框操作
'plugin:toggle',
'plugin:install',
// 权限对话框操作
'permission:toggleDebug',
// 设置配置面板操作
'settings:search',
'settings:retry',
'settings:close',
// 语音操作
'voice:pushToTalk',
] as const
/**
* 单个键绑定块的模式。
*/
export const KeybindingBlockSchema = lazySchema(() =>
z
.object({
context: z
.enum(KEYBINDING_CONTEXTS)
.describe(
'这些绑定适用的 UI 上下文。全局绑定在任何地方都有效。',
),
bindings: z
.record(
z
.string()
.describe('按键模式(例如 "ctrl+k"、"shift+tab"'),
z
.union([
z.enum(KEYBINDING_ACTIONS),
z
.string()
.regex(/^command:[a-zA-Z0-9:\-_]+$/)
.describe(
'命令绑定(例如 "command:help"、"command:compact")。执行,就好像输入了斜杠命令。',
),
z.null().describe('设置为 null 以取消绑定默认快捷键'),
])
.describe(
'要触发的操作、执行的命令,或 null 以取消绑定',
),
)
.describe('按键模式到操作的映射'),
})
.describe('特定上下文的键绑定块'),
)
/**
* 整个 keybindings.json 文件的模式。
* 使用可选的 $schema 和 $docs 元数据的目标对象包装格式。
*/
export const KeybindingsSchema = lazySchema(() =>
z
.object({
$schema: z
.string()
.optional()
.describe('用于编辑器验证的 JSON Schema URL'),
$docs: z.string().optional().describe('文档 URL'),
bindings: z
.array(KeybindingBlockSchema())
.describe('按上下文划分的键绑定块数组'),
})
.describe(
'Claude Code 键绑定配置。按上下文自定义键盘快捷键。',
),
)
/**
* 从模式派生的 TypeScript 类型。
*/
export type KeybindingsSchemaType = z.infer<
ReturnType<typeof KeybindingsSchema>
>

View File

@@ -0,0 +1,52 @@
/**
* 键绑定模板生成器。
* 为 ~/.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'
/**
* 过滤无法重新绑定的保留快捷键。
* 这些会导致 /doctor 警告,因此我们从模板中排除它们。
*/
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)
}
/**
* 生成模板 keybindings.json 文件内容。
* 创建完全有效的 JSON 文件,包含用户可自定义的所有默认绑定。
*/
export function generateKeybindingsTemplate(): string {
// 过滤无法重新绑定的保留快捷键
const bindings = filterReservedShortcuts(DEFAULT_BINDINGS)
// 格式化为带 bindings 数组的对象包装
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 = {
/** 此绑定所属的上下文(默认:'Global' */
context?: KeybindingContextName
/** 仅在激活时处理(类似于 useInput 的 isActive */
isActive?: boolean
}
/**
* 用于处理键绑定的 Ink 原生 hook。
*
* 处理程序保留在组件中React 方式)。
* 绑定(按键 → 操作)来自配置。
*
* 支持和弦序列(例如 "ctrl+k ctrl+s")。当和弦开始时,
* hook 将自动管理待处理状态。
*
* 使用 stopImmediatePropagation() 在此绑定被处理后
* 阻止其他处理程序触发。
*
* @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()
// 向上下文注册处理程序以供 ChordInterceptor 调用
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 (!keybindingContext) return
// 构建上下文列表:注册的活动上下文 + 此上下文 + Global
// 更具体的上下文(注册的)比 Global 优先级更高
const contextsToCheck: KeybindingContextName[] = [
...keybindingContext.activeContexts,
context,
'Global',
]
// 去重同时保持顺序(第一次出现的优先级最高)
const uniqueContexts = [...new Set(contextsToCheck)]
const result = keybindingContext.resolve(input, key, uniqueContexts)
switch (result.type) {
case 'match':
// 和弦完成(如果有)- 清除待处理状态
keybindingContext.setPendingChord(null)
if (result.action === action) {
if (handler() !== false) {
event.stopImmediatePropagation()
}
}
break
case 'chord_started':
// 用户开始了一个和弦序列 - 更新待处理状态
keybindingContext.setPendingChord(result.pending)
event.stopImmediatePropagation()
break
case 'chord_cancelled':
// 和弦被取消escape 或无效键)
keybindingContext.setPendingChord(null)
break
case 'unbound':
// 显式取消绑定 - 清除任何待处理和弦
keybindingContext.setPendingChord(null)
event.stopImmediatePropagation()
break
case 'none':
// 没有匹配 - 让其他处理程序尝试
break
}
},
[action, context, handler, keybindingContext],
)
useInput(handleInput, { isActive })
}
/**
* 在一个 hook 中处理多个键绑定(减少 useInput 调用)。
*
* 支持和弦序列。当和弦开始时hook 将
* 自动管理待处理状态。
*
* @example
* ```tsx
* useKeybindings({
* 'chat:submit': () => handleSubmit(),
* 'chat:cancel': () => handleCancel(),
* }, { context: 'Chat' })
* ```
*/
export function useKeybindings(
// 返回 `false` 的处理程序意味着"未消费" — 事件传播到
// 后续的 useInput/useKeybindings 处理程序。用于回退:
// 例如 ScrollKeybindingHandler 的 scroll:line* 在
// ScrollBox 内容适合时返回 false滚动是无操作
// 让子组件的处理程序接管列表导航的车轮事件。
// Promise<void> 允许火即忘的异步处理程序(`!== false` 检查
// 仅对同步 `false` 跳过传播,而不是待处理的 Promise
handlers: Record<string, () => void | false | Promise<void>>,
options: Options = {},
): void {
const { context = 'Global', isActive = true } = options
const keybindingContext = useOptionalKeybindingContext()
// 向上下文注册所有处理程序以供 ChordInterceptor 调用
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 (!keybindingContext) return
// 构建上下文列表:注册的活动上下文 + 此上下文 + Global
// 更具体的上下文(注册的)比 Global 优先级更高
const contextsToCheck: KeybindingContextName[] = [
...keybindingContext.activeContexts,
context,
'Global',
]
// 去重同时保持顺序(第一次出现的优先级最高)
const uniqueContexts = [...new Set(contextsToCheck)]
const result = keybindingContext.resolve(input, key, uniqueContexts)
switch (result.type) {
case 'match':
// 和弦完成(如果有)- 清除待处理状态
keybindingContext.setPendingChord(null)
if (result.action in handlers) {
const handler = handlers[result.action]
if (handler && handler() !== false) {
event.stopImmediatePropagation()
}
}
break
case 'chord_started':
// 用户开始了一个和弦序列 - 更新待处理状态
keybindingContext.setPendingChord(result.pending)
event.stopImmediatePropagation()
break
case 'chord_cancelled':
// 和弦被取消escape 或无效键)
keybindingContext.setPendingChord(null)
break
case 'unbound':
// 显式取消绑定 - 清除任何待处理和弦
keybindingContext.setPendingChord(null)
event.stopImmediatePropagation()
break
case 'none':
// 没有匹配 - 让其他处理程序尝试
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): 迁移完成后移除 fallback 参数
// 确认没有 'keybinding_fallback_used' 事件被记录。
// fallback 作为迁移期间的安全网存在 - 如果绑定加载失败
// 或找不到操作,我们回退到硬编码值。一旦稳定,调用者
// 应该能够信任 getBindingDisplayText 始终为已知操作返回值,
// 我们可以移除这种防御模式。
/**
* 获取配置快捷键显示文本的 hook。
* 返回配置的绑定或不可用时的回退值。
*
* @param action - 操作名称(例如 'app:toggleTranscript'
* @param context - 键绑定上下文(例如 'Global'
* @param fallback - 如果键绑定上下文不可用时的回退文本
* @returns 配置的快捷键显示文本
*
* @example
* const expandShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o')
* // 返回用户配置的绑定,或 'ctrl+o' 作为默认值
*/
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'
// 在每次挂载时记录一次回退使用(而不是每次渲染)以避免
// 频繁渲染导致的分析事件泛滥。
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
}

View File

@@ -0,0 +1,496 @@
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'
/**
* 键绑定可能发生的验证问题类型。
*/
export type KeybindingWarningType =
| 'parse_error'
| 'duplicate'
| 'reserved'
| 'invalid_context'
| 'invalid_action'
/**
* 关于键绑定配置问题的警告或错误。
*/
export type KeybindingWarning = {
type: KeybindingWarningType
severity: 'error' | 'warning'
message: string
key?: string
context?: string
action?: string
suggestion?: string
}
/**
* 类型守卫,检查对象是否是有效的 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
)
}
/**
* 类型守卫,检查数组是否只包含有效的 KeybindingBlock。
*/
function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] {
return Array.isArray(arr) && arr.every(isKeybindingBlock)
}
/**
* 有效的键绑定上下文名称。
* 必须与 types.ts 中的 KeybindingContextName 匹配
*/
const VALID_CONTEXTS: KeybindingContextName[] = [
'Global',
'Chat',
'Autocomplete',
'Confirmation',
'Help',
'Transcript',
'HistorySearch',
'Task',
'ThemePicker',
'Settings',
'Tabs',
'Attachments',
'Footer',
'MessageSelector',
'DiffDialog',
'ModelPicker',
'Select',
'Plugin',
]
/**
* 类型守卫,检查字符串是否是有效的上下文名称。
*/
function isValidContext(value: string): value is KeybindingContextName {
return (VALID_CONTEXTS as readonly string[]).includes(value)
}
/**
* 验证单个按键字符串并返回任何解析错误。
*/
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: `空按键部分:"${keystroke}"`,
key: keystroke,
suggestion: '删除额外的 "+" 字符',
}
}
}
// 尝试解析并查看是否失败
const parsed = parseKeystroke(keystroke)
if (
!parsed.key &&
!parsed.ctrl &&
!parsed.alt &&
!parsed.shift &&
!parsed.meta
) {
return {
type: 'parse_error',
severity: 'error',
message: `无法解析按键:"${keystroke}"`,
key: keystroke,
}
}
return null
}
/**
* 验证来自用户配置的键绑定块。
*/
function validateBlock(
block: unknown,
blockIndex: number,
): KeybindingWarning[] {
const warnings: KeybindingWarning[] = []
if (typeof block !== 'object' || block === null) {
warnings.push({
type: 'parse_error',
severity: 'error',
message: `键绑定块 ${blockIndex + 1} 不是对象`,
})
return warnings
}
const b = block as Record<string, unknown>
// 验证上下文 - 提取为缩小变量以保证类型安全
const rawContext = b.context
let contextName: string | undefined
if (typeof rawContext !== 'string') {
warnings.push({
type: 'parse_error',
severity: 'error',
message: `键绑定块 ${blockIndex + 1} 缺少 "context" 字段`,
})
} else if (!isValidContext(rawContext)) {
warnings.push({
type: 'invalid_context',
severity: 'error',
message: `未知的上下文:"${rawContext}"`,
context: rawContext,
suggestion: `有效的上下文:${VALID_CONTEXTS.join(', ')}`,
})
} else {
contextName = rawContext
}
// 验证绑定
if (typeof b.bindings !== 'object' || b.bindings === null) {
warnings.push({
type: 'parse_error',
severity: 'error',
message: `键绑定块 ${blockIndex + 1} 缺少 "bindings" 字段`,
})
return warnings
}
const bindings = b.bindings as Record<string, unknown>
for (const [key, action] of Object.entries(bindings)) {
// 验证键语法
const keyError = validateKeystroke(key)
if (keyError) {
keyError.context = contextName
warnings.push(keyError)
}
// 验证操作
if (action !== null && typeof action !== 'string') {
warnings.push({
type: 'invalid_action',
severity: 'error',
message: `"${key}" 的操作无效:必须是字符串或 null`,
key,
context: contextName,
})
} else if (typeof action === 'string' && action.startsWith('command:')) {
// 验证命令绑定格式
if (!/^command:[a-zA-Z0-9:\-_]+$/.test(action)) {
warnings.push({
type: 'invalid_action',
severity: 'warning',
message: `"${key}" 的命令绑定 "${action}" 无效:命令名只能包含字母数字字符、冒号、连字符和下划线`,
key,
context: contextName,
action,
})
}
// 命令绑定必须在聊天上下文中
if (contextName && contextName !== 'Chat') {
warnings.push({
type: 'invalid_action',
severity: 'warning',
message: `命令绑定 "${action}" 必须在 "Chat" 上下文中,而不是 "${contextName}"`,
key,
context: contextName,
action,
suggestion: '将此绑定移动到具有 "context": "Chat" 的块',
})
}
} else if (action === 'voice:pushToTalk') {
// 按键检测需要操作系统自动重复。裸字母在预热期间会打印到输入中,
// 激活条是尽力的 — 空格或像 meta+k 这样的修饰符组合可以避免这种情况。
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: `将 "${key}" 绑定到 voice:pushToTalk 会在预热期间打印到输入中;使用空格或像 meta+k 这样的修饰符组合`,
key,
context: contextName,
action,
})
}
}
}
return warnings
}
/**
* 检测 JSON 字符串中同一绑定块内的重复键。
* JSON.parse 静默使用重复键的最后一个值,
* 因此我们需要检查原始字符串以警告用户。
*
* 仅警告同一上下文的绑定对象内的重复。
* 不同上下文之间的重复是允许的(例如 "enter" 在 Chat 和 Confirmation 中)。
*/
export function checkDuplicateKeysInJson(
jsonString: string,
): KeybindingWarning[] {
const warnings: KeybindingWarning[] = []
// 找到每个 "bindings" 块并检查其内的重复
// 模式:"bindings" : { ... }
const bindingsBlockPattern =
/"bindings"\s*:\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g
let blockMatch
while ((blockMatch = bindingsBlockPattern.exec(jsonString)) !== null) {
const blockContent = blockMatch[1]
if (!blockContent) continue
// 通过向后查找来找到此块的上下文
const textBeforeBlock = jsonString.slice(0, blockMatch.index)
const contextMatch = textBeforeBlock.match(
/"context"\s*:\s*"([^"]+)"[^{]*$/,
)
const context = contextMatch?.[1] ?? 'unknown'
// 找到此绑定块内的所有键
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) {
// 仅在第二次出现时警告
warnings.push({
type: 'duplicate',
severity: 'warning',
message: `${context} 绑定中有重复键 "${key}"`,
key,
context,
suggestion: `此键在同一上下文中出现多次。JSON 使用最后一个值,较早的值被忽略。`,
})
}
}
}
return warnings
}
/**
* 验证用户键绑定配置并返回所有警告。
*/
export function validateUserConfig(userBlocks: unknown): KeybindingWarning[] {
const warnings: KeybindingWarning[] = []
if (!Array.isArray(userBlocks)) {
warnings.push({
type: 'parse_error',
severity: 'error',
message: 'keybindings.json 必须包含一个数组',
suggestion: '将您的绑定包装在 [ ] 中',
})
return warnings
}
for (let i = 0; i < userBlocks.length; i++) {
warnings.push(...validateBlock(userBlocks[i], i))
}
return warnings
}
/**
* 检查同一上下文内的重复绑定。
* 仅检查用户绑定(不是默认 + 用户合并)。
*/
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: `${block.context} 上下文中有重复绑定 "${key}"`,
key,
context: block.context,
action: action ?? 'null (unbind)',
suggestion: `之前绑定到 "${existingAction}"。只会使用最后一个绑定。`,
})
}
contextMap.set(normalizedKey, action ?? 'null')
}
}
return warnings
}
/**
* 检查可能不工作的保留快捷键。
*/
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)
// 检查保留快捷键
for (const res of reserved) {
if (normalizeKeyForComparison(res.key) === normalizedKey) {
warnings.push({
type: 'reserved',
severity: res.severity,
message: `"${keyDisplay}" 可能不工作:${res.reason}`,
key: keyDisplay,
context: binding.context,
action: binding.action ?? undefined,
})
}
}
}
return warnings
}
/**
* 将用户块解析为绑定以进行验证。
* 这与主解析器分开以避免导入它。
*/
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
}
/**
* 运行所有验证并返回合并的警告。
*/
export function validateBindings(
userBlocks: unknown,
_parsedBindings: ParsedBinding[],
): KeybindingWarning[] {
const warnings: KeybindingWarning[] = []
// 验证用户配置结构
warnings.push(...validateUserConfig(userBlocks))
// 检查用户配置中的重复
if (isKeybindingBlockArray(userBlocks)) {
warnings.push(...checkDuplicates(userBlocks))
// 检查保留/冲突快捷键 - 仅检查用户绑定
const userBindings = getUserBindingsForValidation(userBlocks)
warnings.push(...checkReservedShortcuts(userBindings))
}
// 去重警告(相同的 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
})
}
/**
* 格式化警告以显示给用户。
*/
export function formatWarning(warning: KeybindingWarning): string {
const icon = warning.severity === 'error' ? '✗' : '⚠'
let msg = `${icon} 键绑定 ${warning.severity}${warning.message}`
if (warning.suggestion) {
msg += `\n ${warning.suggestion}`
}
return msg
}
/**
* 格式化多个警告。
*/
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(
`发现 ${errors.length} 个键绑定 ${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(
`发现 ${warns.length} 个键绑定 ${plural(warns.length, 'warning')}`,
)
for (const w of warns) {
lines.push(formatWarning(w))
}
}
return lines.join('\n')
}