本章总览
Claude Code 终端 REPL 的键盘体验由 hooks/ 下四条输入/快捷键链路共同构成:useSearchInput 提供类 less/vim 的搜索框编辑;useGlobalKeybindings 注册全局视图切换;useCommandKeybindings 把用户 keybinding 配置里的 command:* 动作映射为斜杠命令;useVimInput 在 VimTextInput 上叠加 NORMAL/INSERT 模式与 dot-repeat。它们都运行在 Ink 事件模型之上,但职责边界清晰——改 transcript 搜索不应动 prompt 的 vim 状态,改 ctrl+t 不应改 slash 命令绑定。
学完本章你应该能
- 解释 useSearchInput 的 onExit / onCancel 语义与 backspaceExitsOnEmpty 选项
- 说明 useInput 向后兼容桥接与 onKeyDown 迁移计划
- 列举 GlobalKeybindingHandlers 注册的 app:* 与 transcript:* 动作
- 理解 CommandKeybindingHandlers 如何从 KeybindingContext 动态发现 command:*
- 画出 useVimInput 与 useTextInput 的组合关系及 Esc 为何不迁入 keybindings 系统
- 能在 REPL.tsx 中定位四条 Hook 的挂载点与 isActive 门控
核心概念(先读懂这些)
两层快捷键:声明式 vs 输入内联
声明式层(useGlobalKeybindings / useCommandKeybindings)通过 useKeybinding / useKeybindings 向 KeybindingSetup 注册,由 keybindings 模块解析 chord、context、isActive。内联层(useSearchInput / useVimInput)在 handleKeyDown / handleVimInput 里直接读 KeyboardEvent,适合需要 readline 语义(kill ring、yank-pop、vim operator)的场景。混用时注意事件冒泡:transcript 搜索栏打开时 GlobalKeybindingHandlers 会 gate transcript:exit,避免 Esc 同时触发 onCancel 与退出 transcript。
Cursor 工具类与 kill ring
useSearchInput 不自己实现字符插入,而是每次按键用 Cursor.fromText(query, columns, offset) 构造不可变 cursor,再 backspace/insert/deleteWordBefore 等。kill ring 状态在 utils/Cursor.js 模块级维护,与 Emacs readline 行为对齐。非 kill 键会 resetKillAccumulation,非 yank 键 resetYankState——这是多步 kill 与 yank-pop 正确性的前提。
Vim 输入不配置 Esc
useVimInput 注释明确 NOTE(keybindings):INSERT 模式 Esc 切 NORMAL 是 vim 内置行为,故意不迁入可配置 keybindings,以免 vim 用户期望被破坏。NORMAL 模式 Esc 则取消 pending operator。Ctrl 组合键一律 delegate 给底层 useTextInput,保证 ctrl+c 等仍走 REPL 全局逻辑。
建议学习步骤
- 阅读 useSearchInput 选项类型与 UNHANDLED_SPECIAL_KEYS 设计
- 对照 handleKeyDown 分支:退出、backspace、ctrl/meta、普通字符
- 打开 useGlobalKeybindings,梳理 useKeybinding 注册表
- 阅读 useCommandKeybindings 的 commandActions 扫描与 NOOP_HELPERS
- 在 useVimInput 中跟踪 handleVimInput 的模式分支与 transition
- 在 REPL.tsx 搜索 GlobalKeybindingHandlers / useSearchInput 调用
常见误区
注意
useSearchInput 底部仍订阅 useInput——未迁移的 11 个 call site 依赖此桥接,勿删
注意
CommandKeybindingHandlers 在 modal overlay 或 local JSX 命令激活时 isActive 为 false
注意
useVimInput 的 inputFilter 在 NORMAL 模式只 disarm 状态、不应用变换后的 input
注意
不要把 utils/hooks.ts 的 Shell Hook 与 hooks/ 目录 React Hook 混淆
在 REPL 架构中的位置
键盘事件在 Claude Code 中的流向可概括为:
终端 raw input → Ink KeyboardEvent
├─ KeybindingSetup(context: Global / Chat / Transcript)
│ ├─ GlobalKeybindingHandlers(ctrl+t/o/e、meta+j…)
│ └─ CommandKeybindingHandlers(command:commit → /commit)
├─ PromptInput / VimTextInput(useVimInput → useTextInput)
└─ Transcript 搜索 overlay(useSearchInput.handleKeyDown)
REPL.tsx 在 <KeybindingSetup> 内渲染 Global 与 Command 两个零 UI 组件;PromptInput 与 TranscriptSearchBar 各自消费输入 Hook。调试「按键无反应」时,先查 isActive 与 context,再查 overlay 是否吞事件。
useSearchInput:选项契约与 less/vim 语义
useSearchInput 服务于 transcript 内 / 搜索、Settings 列表过滤等「单行查询」场景。核心选项:
| 选项 | 含义 |
|---|---|
onExit | Enter / Down 提交搜索 |
onCancel | Esc / Ctrl+G(若提供)放弃,区别于 commit |
onExitUp | Up 键可选回调 |
backspaceExitsOnEmpty | 空串时 Backspace 是否退出(less 删过 / 即退出) |
passthroughCtrlKeys | 允许透传特定 ctrl 组合给外层 |
设计要点: 若提供 onCancel,单次 Esc 直接 cancel,不再「先清空再 Esc 退出」;未提供 onCancel 时 Esc 先清 query,空串再 onExit。Ctrl+C 在无 onCancel 时静默忽略——多数 call site 期望不干扰 REPL 中断逻辑。
返回值 { query, setQuery, cursorOffset, handleKeyDown } 供组件绑定;setQuery 会同步把 cursor 移到末尾。
源码引用: src/hooks/useSearchInput.ts · 第 17–40 行(共 365 行)
17| type UseSearchInputOptions = {
18| isActive: boolean
19| onExit: () => void
20| /** Esc + Ctrl+C abandon (distinct from onExit = Enter commit). When
21| * provided: single-Esc calls this directly (no clear-first-then-exit
22| * two-press). When absent: current behavior — Esc clears non-empty
23| * query, exits on empty; Ctrl+C silently swallowed (no switch case). */
24| onCancel?: () => void
25| onExitUp?: () => void
26| columns?: number
27| passthroughCtrlKeys?: string[]
28| initialQuery?: string
29| /** Backspace (and ctrl+h) on empty query calls onCancel ?? onExit — the
30| * less/vim "delete past the /" convention. Dialogs that want Esc-only
31| * cancel set this false so a held backspace doesn't eject the user. */
32| backspaceExitsOnEmpty?: boolean
33| }
34|
35| type UseSearchInputReturn = {
36| query: string
37| setQuery: (q: string) => void
38| cursorOffset: number
39| handleKeyDown: (e: KeyboardEvent) => void
40| }
源码引用: src/hooks/useSearchInput.ts · 第 63–82 行(共 365 行)
63| const UNHANDLED_SPECIAL_KEYS = new Set([
64| 'pageup',
65| 'pagedown',
66| 'insert',
67| 'wheelup',
68| 'wheeldown',
69| 'mouse',
70| 'f1',
71| 'f2',
72| 'f3',
73| 'f4',
74| 'f5',
75| 'f6',
76| 'f7',
77| 'f8',
78| 'f9',
79| 'f10',
80| 'f11',
81| 'f12',
82| ])
useSearchInput:handleKeyDown 分支走读
handleKeyDown 是本章最密集的键盘逻辑,建议按下列顺序阅读:
- 门控:
!isActive早退;passthrough ctrl 键直接 return - kill/yank 状态:非 kill 键 resetKillAccumulation;非 yank 键 resetYankState
- 退出:return/down → onExit;up → onExitUp;escape → onCancel 或清 query
- 编辑:backspace/delete;带 meta/ctrl/fn 的箭头词跳;home/end
- Ctrl 绑定:Emacs 风格 a/e/b/f/d/h/k/u/w/y;空行 ctrl+d/h 可触发 cancel
- Meta 绑定:词移、meta+d 删词、meta+y yank-pop
- 普通字符:
e.key.length >= 1且不在 UNHANDLED_SPECIAL_KEYS
UNHANDLED_SPECIAL_KEYS 拦截 pageup、f1–f12 等,防止泄漏为字面文本。注释强调 batched stdin(如 paste)可能一次传入多字符 e.key,与旧 useInput 行为一致。
文件末尾 useInput 桥接(TODO onKeyDown-migration):尚未把 handleKeyDown 接到 <Box onKeyDown> 的 consumer 仍通过 useInput 订阅,适配 InputEvent → KeyboardEvent。
源码引用: src/hooks/useSearchInput.ts · 第 104–148 行(共 365 行)
104| const handleKeyDown = (e: KeyboardEvent): void => {
105| if (!isActive) return
106|
107| const cursor = Cursor.fromText(query, effectiveColumns, cursorOffset)
108|
109| // Check passthrough ctrl keys
110| if (e.ctrl && passthroughCtrlKeys.includes(e.key.toLowerCase())) {
111| return
112| }
113|
114| // Reset kill accumulation for non-kill keys
115| if (!isKillKey(e)) {
116| resetKillAccumulation()
117| }
118|
119| // Reset yank state for non-yank keys
120| if (!isYankKey(e)) {
121| resetYankState()
122| }
123|
124| // Exit conditions
125| if (e.key === 'return' || e.key === 'down') {
126| e.preventDefault()
127| onExit()
128| return
129| }
130| if (e.key === 'up') {
131| e.preventDefault()
132| if (onExitUp) {
133| onExitUp()
134| }
135| return
136| }
137| if (e.key === 'escape') {
138| e.preventDefault()
139| if (onCancel) {
140| onCancel()
141| } else if (query.length > 0) {
142| setQueryState('')
143| setCursorOffset(0)
144| } else {
145| onExit()
146| }
147| return
148| }
源码引用: src/hooks/useSearchInput.ts · 第 221–300 行(共 365 行)
221| // Ctrl key bindings
222| if (e.ctrl) {
223| e.preventDefault()
224| switch (e.key.toLowerCase()) {
225| case 'a':
226| setCursorOffset(0)
227| return
228| case 'e':
229| setCursorOffset(query.length)
230| return
231| case 'b':
232| setCursorOffset(cursor.left().offset)
233| return
234| case 'f':
235| setCursorOffset(cursor.right().offset)
236| return
237| case 'd': {
238| if (query.length === 0) {
239| ;(onCancel ?? onExit)()
240| return
241| }
242| const newCursor = cursor.del()
243| setQueryState(newCursor.text)
244| setCursorOffset(newCursor.offset)
245| return
246| }
247| case 'h': {
248| if (query.length === 0) {
249| if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
250| return
251| }
252| const newCursor = cursor.backspace()
253| setQueryState(newCursor.text)
254| setCursorOffset(newCursor.offset)
255| return
256| }
257| case 'k': {
258| const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
259| pushToKillRing(killed, 'append')
260| setQueryState(newCursor.text)
261| setCursorOffset(newCursor.offset)
262| return
263| }
264| case 'u': {
265| const { cursor: newCursor, killed } = cursor.deleteToLineStart()
266| pushToKillRing(killed, 'prepend')
267| setQueryState(newCursor.text)
268| setCursorOffset(newCursor.offset)
269| return
270| }
271| case 'w': {
272| const { cursor: newCursor, killed } = cursor.deleteWordBefore()
273| pushToKillRing(killed, 'prepend')
274| setQueryState(newCursor.text)
275| setCursorOffset(newCursor.offset)
276| return
277| }
278| case 'y': {
279| const text = getLastKill()
280| if (text.length > 0) {
281| const startOffset = cursor.offset
282| const newCursor = cursor.insert(text)
283| recordYank(startOffset, text.length)
284| setQueryState(newCursor.text)
285| setCursorOffset(newCursor.offset)
286| }
287| return
288| }
289| case 'g':
290| case 'c':
291| // Cancel (abandon search). ctrl+g is less's cancel key. Only
292| // fires if onCancel provided — otherwise falls through and
293| // returns silently (11 call sites, most expect ctrl+c to no-op).
294| if (onCancel) {
295| onCancel()
296| return
297| }
298| }
299| return
300| }
源码引用: src/hooks/useSearchInput.ts · 第 352–364 行(共 365 行)
352| // Backward-compat bridge: existing consumers don't yet wire handleKeyDown
353| // to <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
354| // KeyboardEvent until all 11 call sites are migrated (separate PRs).
355| // TODO(onKeyDown-migration): remove once all consumers pass handleKeyDown.
356| useInput(
357| (_input, _key, event) => {
358| handleKeyDown(new KeyboardEvent(event.keypress))
359| },
360| { isActive },
361| )
362|
363| return { query, setQuery, cursorOffset, handleKeyDown }
364| }
REPL 中 Transcript 搜索对 useSearchInput 的用法
REPL.tsx 的 TranscriptSearchBar 传入:
onExit: () => onClose(query)— Enter 提交,保留 query 供 n/N 导航onCancel— Esc 撤销到打开搜索前状态initialQuery— less 风格:再次/显示上次 pattern
这与 GlobalKeybindingHandlers 的 searchBarOpen gate 联动:搜索栏打开时 transcript:exit 不 active,否则 Esc 会同时触发 useSearchInput.onCancel 与退出 transcript(useSearchInput 不 stopPropagation,子 handler 先 fire 再 bubble)。
工程练习: 在 transcript 模式打开 / 搜索,按 Esc 观察是仅 cancel 搜索还是整页退出——取决于 searchBarOpen 状态。
源码引用: src/screens/REPL.tsx · 第 380–398 行(共 7050 行)
380| } from '../utils/fileHistory.js'
381| import {
382| type AttributionState,
383| incrementPromptCount,
384| } from '../utils/commitAttribution.js'
385| import { recordAttributionSnapshot } from '../utils/sessionStorage.js'
386| import {
387| computeStandaloneAgentContext,
388| restoreAgentFromSession,
389| restoreSessionStateFromLog,
390| restoreWorktreeForResume,
391| exitRestoredWorktree,
392| } from '../utils/sessionRestore.js'
393| import {
394| isBgSession,
395| updateSessionName,
396| updateSessionActivity,
397| } from '../utils/concurrentSessions.js'
398| import {
源码引用: src/hooks/useGlobalKeybindings.tsx · 第 238–246 行(共 265 行)
238|
239| // Clear screen and force full redraw (ctrl+l). Recovery path when the
240| // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine
241| // thinks unchanged cells don't need repainting.
242| const handleRedraw = useCallback(() => {
243| instances.get(process.stdout)?.forceRedraw()
244| }, [])
245| useKeybinding('app:redraw', handleRedraw, { context: 'Global' })
246|
useGlobalKeybindings:全局视图与 transcript 专用键
GlobalKeybindingHandlers 是 零 UI 组件(return null),必须在 KeybindingSetup 内渲染。
app 级绑定(context: Global):
app:toggleTodos— ctrl+t,expandedView 在 none/tasks/teammates 间循环(有 running teammate 时三态,否则 none↔tasks)app:toggleTranscript— ctrl+o,prompt↔transcript;KAIROS brief stuck 时先清 isBriefOnlyapp:toggleBrief— ctrl+shift+b(feature gate)app:toggleTeammatePreview、app:toggleTerminal(meta+j)、app:redraw(ctrl+l 强制 Ink 重绘)
transcript 级绑定(context: Transcript,带 isActive):
transcript:toggleShowAll— ctrl+e,且!virtualScrollActivetranscript:exit— ctrl+c/escape,且!searchBarOpen
每次切换写 analytics(tengu_toggle_transcript 等),便于产品分析 transcript 使用率。
源码引用: src/hooks/useGlobalKeybindings.tsx · 第 29–88 行(共 265 行)
29| virtualScrollActive?: boolean
30| searchBarOpen?: boolean
31| }
32|
33| /**
34| * Registers global keybinding handlers for:
35| * - ctrl+t: Toggle todo list
36| * - ctrl+o: Toggle transcript mode
37| * - ctrl+e: Toggle showing all messages in transcript
38| * - ctrl+c/escape: Exit transcript mode
39| */
40| export function GlobalKeybindingHandlers({
41| screen,
42| setScreen,
43| showAllInTranscript,
44| setShowAllInTranscript,
45| messageCount,
46| onEnterTranscript,
47| onExitTranscript,
48| virtualScrollActive,
49| searchBarOpen = false,
50| }: Props): null {
51| const expandedView = useAppState(s => s.expandedView)
52| const setAppState = useSetAppState()
53|
54| // Toggle todo list (ctrl+t) - cycles through views
55| const handleToggleTodos = useCallback(() => {
56| logEvent('tengu_toggle_todos', {
57| is_expanded: expandedView === 'tasks',
58| })
59| setAppState(prev => {
60| const { getAllInProcessTeammateTasks } =
61| // eslint-disable-next-line @typescript-eslint/no-require-imports
62| require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js')
63| const hasTeammates =
64| count(
65| getAllInProcessTeammateTasks(prev.tasks),
66| t => t.status === 'running',
67| ) > 0
68|
69| if (hasTeammates) {
70| // Both exist: none → tasks → teammates → none
71| switch (prev.expandedView) {
72| case 'none':
73| return { ...prev, expandedView: 'tasks' as const }
74| case 'tasks':
75| return { ...prev, expandedView: 'teammates' as const }
76| case 'teammates':
77| return { ...prev, expandedView: 'none' as const }
78| }
79| }
80| // Only tasks: none ↔ tasks
81| return {
82| ...prev,
83| expandedView:
84| prev.expandedView === 'tasks'
85| ? ('none' as const)
86| : ('tasks' as const),
87| }
88| })
源码引用: src/hooks/useGlobalKeybindings.tsx · 第 90–154 行(共 265 行)
90|
91| // Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
92| // Brief view has its own dedicated toggle on ctrl+shift+b.
93| const isBriefOnly =
94| feature('KAIROS') || feature('KAIROS_BRIEF')
95| ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
96| useAppState(s => s.isBriefOnly)
97| : false
98| const handleToggleTranscript = useCallback(() => {
99| if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
100| // Escape hatch: GB kill-switch while defaultView=chat was persisted
101| // can leave isBriefOnly stuck on, showing a blank filterForBriefTool
102| // view. Users will reach for ctrl+o — clear the stuck state first.
103| // Only needed in the prompt screen — transcript mode already ignores
104| // isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode).
105| /* eslint-disable @typescript-eslint/no-require-imports */
106| const { isBriefEnabled } =
107| require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js')
108| /* eslint-enable @typescript-eslint/no-require-imports */
109| if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') {
110| setAppState(prev => {
111| if (!prev.isBriefOnly) return prev
112| return { ...prev, isBriefOnly: false }
113| })
114| return
115| }
116| }
117|
118| const isEnteringTranscript = screen !== 'transcript'
119| logEvent('tengu_toggle_transcript', {
120| is_entering: isEnteringTranscript,
121| show_all: showAllInTranscript,
122| message_count: messageCount,
123| })
124| setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript'))
125| setShowAllInTranscript(false)
126| if (isEnteringTranscript && onEnterTranscript) {
127| onEnterTranscript()
128| }
129| if (!isEnteringTranscript && onExitTranscript) {
130| onExitTranscript()
131| }
132| }, [
133| screen,
134| setScreen,
135| isBriefOnly,
136| showAllInTranscript,
137| setShowAllInTranscript,
138| messageCount,
139| setAppState,
140| onEnterTranscript,
141| onExitTranscript,
142| ])
143|
144| // Toggle showing all messages in transcript mode (ctrl+e)
145| const handleToggleShowAll = useCallback(() => {
146| logEvent('tengu_transcript_toggle_show_all', {
147| is_expanding: !showAllInTranscript,
148| message_count: messageCount,
149| })
150| setShowAllInTranscript(prev => !prev)
151| }, [showAllInTranscript, setShowAllInTranscript, messageCount])
152|
153| // Exit transcript mode (ctrl+c or escape)
154| const handleExitTranscript = useCallback(() => {
源码引用: src/hooks/useGlobalKeybindings.tsx · 第 184–246 行(共 265 行)
184| logEvent('tengu_brief_mode_toggled', {
185| enabled: next,
186| gated: false,
187| source:
188| 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
189| })
190| setAppState(prev => {
191| if (prev.isBriefOnly === next) return prev
192| return { ...prev, isBriefOnly: next }
193| })
194| }
195| }, [isBriefOnly, setAppState])
196|
197| // Register keybinding handlers
198| useKeybinding('app:toggleTodos', handleToggleTodos, {
199| context: 'Global',
200| })
201| useKeybinding('app:toggleTranscript', handleToggleTranscript, {
202| context: 'Global',
203| })
204| if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
205| // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
206| useKeybinding('app:toggleBrief', handleToggleBrief, {
207| context: 'Global',
208| })
209| }
210|
211| // Register teammate keybinding
212| useKeybinding(
213| 'app:toggleTeammatePreview',
214| () => {
215| setAppState(prev => ({
216| ...prev,
217| showTeammateMessagePreview: !prev.showTeammateMessagePreview,
218| }))
219| },
220| {
221| context: 'Global',
222| },
223| )
224|
225| // Toggle built-in terminal panel (meta+j).
226| // toggle() blocks in spawnSync until the user detaches from tmux.
227| const handleToggleTerminal = useCallback(() => {
228| if (feature('TERMINAL_PANEL')) {
229| if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) {
230| return
231| }
232| getTerminalPanel().toggle()
233| }
234| }, [])
235| useKeybinding('app:toggleTerminal', handleToggleTerminal, {
236| context: 'Global',
237| })
238|
239| // Clear screen and force full redraw (ctrl+l). Recovery path when the
240| // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine
241| // thinks unchanged cells don't need repainting.
242| const handleRedraw = useCallback(() => {
243| instances.get(process.stdout)?.forceRedraw()
244| }, [])
245| useKeybinding('app:redraw', handleRedraw, { context: 'Global' })
246|
useCommandKeybindings:配置驱动的斜杠命令
CommandKeybindingHandlers 扫描 KeybindingContext 里所有 action.startsWith("command:") 的绑定,动态构建 handler map:
command:commit → onSubmit("/commit", NOOP_HELPERS, undefined, { fromKeybinding: true })
关键语义:
- immediate 执行 — 不等用户编辑 prompt,直接走 handlePromptSubmit 路径
- 保留输入 — NOOP_HELPERS 的 clearBuffer/setCursorOffset 均为空操作,现有 prompt 文本不清空
- fromKeybinding: true — 下游可区分键盘触发 vs 手动输入
- isActive 门控 —
isActive && !isModalOverlayActive;local JSX 命令打开时 REPL 传isActive={!toolJSX?.isLocalJSXCommand}
React Compiler 生成的 _c / $[] memo 可忽略,关注 commandActions 的 useMemo 与 handlers map 构建即可。无 KeybindingContext 时 commandActions 为空 Set,不注册任何绑定。
源码引用: src/hooks/useCommandKeybindings.tsx · 第 17–30 行(共 83 行)
17| type Props = {
18| // onSubmit accepts additional parameters beyond what we pass here,
19| // so we use a rest parameter to allow any additional args
20| onSubmit: (
21| input: string,
22| helpers: PromptInputHelpers,
23| ...rest: [
24| speculationAccept?: undefined,
25| options?: { fromKeybinding?: boolean },
26| ]
27| ) => void
28| /** Set to false to disable command keybindings (e.g., when a dialog is open) */
29| isActive?: boolean
30| }
源码引用: src/hooks/useCommandKeybindings.tsx · 第 59–83 行(共 83 行)
59| return actions
60| }, [keybindingContext])
61|
62| // Build handler map for all command actions
63| const handlers = useMemo(() => {
64| const map: Record<string, () => void> = {}
65| for (const action of commandActions) {
66| const commandName = action.slice('command:'.length)
67| map[action] = () => {
68| onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, {
69| fromKeybinding: true,
70| })
71| }
72| }
73| return map
74| }, [commandActions, onSubmit])
75|
76| useKeybindings(handlers, {
77| context: 'Chat',
78| isActive: isActive && !isModalOverlayActive,
79| })
80|
81| return null
82| }
83|
useVimInput:模式机与 useTextInput 组合
useVimInput 包装 useTextInput(不传 inputFilter 给底层),在 handleVimInput 最外层统一跑 inputFilter:
- INSERT 模式 — 使用 filter 后的 input,并累积 insertedText 供 dot-repeat
- NORMAL 模式 — 用 rawInput 做 vim 命令 lookup,避免 stateful filter prepend 空格破坏单字符命令
模式切换:
- INSERT + Esc → switchToNormalMode(vim 惯例:光标左移一格,除非行首或前一字符为换行)
- NORMAL + Esc → command 重置为 idle
- Enter 任意模式都 delegate 给 textInput.onInput(允许 NORMAL 下提交)
NORMAL 模式 通过 transition(state.command, vimInput, ctx) 驱动 vim/transitions.js;箭头在 idle/count/operator 态映射为 hjkl;expectsMotion 时 backspace→h、delete→x(count 态除外,避免误删)。
replayLastChange 读取 persistentRef.lastChange,支持 insert/x/replace/operator 等类型的 . 重复。
源码引用: src/hooks/useVimInput.ts · 第 34–80 行(共 317 行)
34| export function useVimInput(props: UseVimInputProps): VimInputState {
35| const vimStateRef = React.useRef<VimState>(createInitialVimState())
36| const [mode, setMode] = useState<VimMode>('INSERT')
37|
38| const persistentRef = React.useRef<PersistentState>(
39| createInitialPersistentState(),
40| )
41|
42| // inputFilter is applied once at the top of handleVimInput (not here) so
43| // vim-handled paths that return without calling textInput.onInput still
44| // run the filter — otherwise a stateful filter (e.g. lazy-space-after-
45| // pill) stays armed across an Escape → NORMAL → INSERT round-trip.
46| const textInput = useTextInput({ ...props, inputFilter: undefined })
47| const { onModeChange, inputFilter } = props
48|
49| const switchToInsertMode = useCallback(
50| (offset?: number): void => {
51| if (offset !== undefined) {
52| textInput.setOffset(offset)
53| }
54| vimStateRef.current = { mode: 'INSERT', insertedText: '' }
55| setMode('INSERT')
56| onModeChange?.('INSERT')
57| },
58| [textInput, onModeChange],
59| )
60|
61| const switchToNormalMode = useCallback((): void => {
62| const current = vimStateRef.current
63| if (current.mode === 'INSERT' && current.insertedText) {
64| persistentRef.current.lastChange = {
65| type: 'insert',
66| text: current.insertedText,
67| }
68| }
69|
70| // Vim behavior: move cursor left by 1 when exiting insert mode
71| // (unless at beginning of line or at offset 0)
72| const offset = textInput.offset
73| if (offset > 0 && props.value[offset - 1] !== '\n') {
74| textInput.setOffset(offset - 1)
75| }
76|
77| vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
78| setMode('NORMAL')
79| onModeChange?.('NORMAL')
80| }, [onModeChange, textInput, props.value])
源码引用: src/hooks/useVimInput.ts · 第 175–228 行(共 317 行)
175| function handleVimInput(rawInput: string, key: Key): void {
176| const state = vimStateRef.current
177| // Run inputFilter in all modes so stateful filters disarm on any key,
178| // but only apply the transformed input in INSERT — NORMAL-mode command
179| // lookups expect single chars and a prepended space would break them.
180| const filtered = inputFilter ? inputFilter(rawInput, key) : rawInput
181| const input = state.mode === 'INSERT' ? filtered : rawInput
182| const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
183|
184| if (key.ctrl) {
185| textInput.onInput(input, key)
186| return
187| }
188|
189| // NOTE(keybindings): This escape handler is intentionally NOT migrated to the keybindings system.
190| // It's vim's standard INSERT->NORMAL mode switch - a vim-specific behavior that should not be
191| // configurable via keybindings. Vim users expect Esc to always exit INSERT mode.
192| if (key.escape && state.mode === 'INSERT') {
193| switchToNormalMode()
194| return
195| }
196|
197| // Escape in NORMAL mode cancels any pending command (replace, operator, etc.)
198| if (key.escape && state.mode === 'NORMAL') {
199| vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
200| return
201| }
202|
203| // Pass Enter to base handler regardless of mode (allows submission from NORMAL)
204| if (key.return) {
205| textInput.onInput(input, key)
206| return
207| }
208|
209| if (state.mode === 'INSERT') {
210| // Track inserted text for dot-repeat
211| if (key.backspace || key.delete) {
212| if (state.insertedText.length > 0) {
213| vimStateRef.current = {
214| mode: 'INSERT',
215| insertedText: state.insertedText.slice(
216| 0,
217| -(lastGrapheme(state.insertedText).length || 1),
218| ),
219| }
220| }
221| } else {
222| vimStateRef.current = {
223| mode: 'INSERT',
224| insertedText: state.insertedText + input,
225| }
226| }
227| textInput.onInput(input, key)
228| return
源码引用: src/hooks/useVimInput.ts · 第 245–295 行(共 317 行)
245| const ctx: TransitionContext = {
246| ...createOperatorContext(cursor, false),
247| onUndo: props.onUndo,
248| onDotRepeat: replayLastChange,
249| }
250|
251| // Backspace/Delete are only mapped in motion-expecting states. In
252| // literal-char states (replace, find, operatorFind), mapping would turn
253| // r+Backspace into "replace with h" and df+Delete into "delete to next x".
254| // Delete additionally skips count state: in vim, N<Del> removes a count
255| // digit rather than executing Nx; we don't implement digit removal but
256| // should at least not turn a cancel into a destructive Nx.
257| const expectsMotion =
258| state.command.type === 'idle' ||
259| state.command.type === 'count' ||
260| state.command.type === 'operator' ||
261| state.command.type === 'operatorCount'
262|
263| // Map arrow keys to vim motions in NORMAL mode
264| let vimInput = input
265| if (key.leftArrow) vimInput = 'h'
266| else if (key.rightArrow) vimInput = 'l'
267| else if (key.upArrow) vimInput = 'k'
268| else if (key.downArrow) vimInput = 'j'
269| else if (expectsMotion && key.backspace) vimInput = 'h'
270| else if (expectsMotion && state.command.type !== 'count' && key.delete)
271| vimInput = 'x'
272|
273| const result = transition(state.command, vimInput, ctx)
274|
275| if (result.execute) {
276| result.execute()
277| }
278|
279| // Update command state (only if execute didn't switch to INSERT)
280| if (vimStateRef.current.mode === 'NORMAL') {
281| if (result.next) {
282| vimStateRef.current = { mode: 'NORMAL', command: result.next }
283| } else if (result.execute) {
284| vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
285| }
286| }
287|
288| if (
289| input === '?' &&
290| state.mode === 'NORMAL' &&
291| state.command.type === 'idle'
292| ) {
293| props.onChange('?')
294| }
295| }
VimTextInput 集成与外部 setMode
components/VimTextInput.tsx 调用 useVimInput 并暴露 mode 给 UI(模式指示器)。外部可通过返回的 setMode 强制 INSERT/NORMAL,用于设置页切换 vim 开关等场景。
useVimInput 返回 { ...textInput, onInput: handleVimInput, mode, setMode } — 对 PromptInput 而言,唯一替换点是 onInput;其余 offset、history 行为与纯 text input 一致。
若你实现新的 multiline 输入组件,优先复用 useTextInput + 可选 useVimInput,而非在组件内复制 Esc/operator 逻辑。
源码引用: src/hooks/useVimInput.ts · 第 297–316 行(共 317 行)
297| const setModeExternal = useCallback(
298| (newMode: VimMode) => {
299| if (newMode === 'INSERT') {
300| vimStateRef.current = { mode: 'INSERT', insertedText: '' }
301| } else {
302| vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
303| }
304| setMode(newMode)
305| onModeChange?.(newMode)
306| },
307| [onModeChange],
308| )
309|
310| return {
311| ...textInput,
312| onInput: handleVimInput,
313| mode,
314| setMode: setModeExternal,
315| }
316| }
源码目录(本主题相关文件)
关联:keybindings/useKeybinding.js、utils/Cursor.js、vim/transitions.js、components/VimTextInput.tsx、screens/REPL.tsx(KeybindingSetup subtree)。
动手练习
- 在 settings.json 添加
command:doctor类 keybinding,验证 CommandKeybindingHandlers 是否自动注册 - transcript 模式:ctrl+o 进入 →
/打开搜索 → Esc,确认只关闭搜索栏 - 开启 vim 模式,INSERT 下输入文字再 Esc,观察光标是否左移
- useSearchInput 单元场景:空 query 时 ctrl+u kill 到行首,再 ctrl+y yank 回来
- 对照 useGlobalKeybindings 注释,理解 ctrl+l forceRedraw 在 macOS Cmd+K 清屏后的 recovery 用途
本章小结与延伸
输入与快捷键 Hook = REPL 键盘 UX 的四条专管线。下一章建议 merged-state,理解工具池与命令队列如何与输入并行运行。 继续学习: