/ src / keybindings / defaultBindings.ts
defaultBindings.ts
  1  import { feature } from 'bun:bundle'
  2  import { satisfies } from 'src/utils/semver.js'
  3  import { isRunningWithBun } from '../utils/bundledMode.js'
  4  import { getPlatform } from '../utils/platform.js'
  5  import type { KeybindingBlock } from './types.js'
  6  
  7  /**
  8   * Default keybindings that match current Claude Code behavior.
  9   * These are loaded first, then user keybindings.json overrides them.
 10   */
 11  
 12  // Platform-specific image paste shortcut:
 13  // - Windows: alt+v (ctrl+v is system paste)
 14  // - Other platforms: ctrl+v
 15  const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v'
 16  
 17  // Modifier-only chords (like shift+tab) may fail on Windows Terminal without VT mode
 18  // See: https://github.com/microsoft/terminal/issues/879#issuecomment-618801651
 19  // Node enabled VT mode in 24.2.0 / 22.17.0: https://github.com/nodejs/node/pull/58358
 20  // Bun enabled VT mode in 1.2.23: https://github.com/oven-sh/bun/pull/21161
 21  const SUPPORTS_TERMINAL_VT_MODE =
 22    getPlatform() !== 'windows' ||
 23    (isRunningWithBun()
 24      ? satisfies(process.versions.bun, '>=1.2.23')
 25      : satisfies(process.versions.node, '>=22.17.0 <23.0.0 || >=24.2.0'))
 26  
 27  // Platform-specific mode cycle shortcut:
 28  // - Windows without VT mode: meta+m (shift+tab doesn't work reliably)
 29  // - Other platforms: shift+tab
 30  const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m'
 31  
 32  export const DEFAULT_BINDINGS: KeybindingBlock[] = [
 33    {
 34      context: 'Global',
 35      bindings: {
 36        // ctrl+c and ctrl+d use special time-based double-press handling.
 37        // They ARE defined here so the resolver can find them, but they
 38        // CANNOT be rebound by users - validation in reservedShortcuts.ts
 39        // will show an error if users try to override these keys.
 40        'ctrl+c': 'app:interrupt',
 41        'ctrl+d': 'app:exit',
 42        'ctrl+l': 'app:redraw',
 43        'ctrl+t': 'app:toggleTodos',
 44        'ctrl+o': 'app:toggleTranscript',
 45        ...(feature('KAIROS') || feature('KAIROS_BRIEF')
 46          ? { 'ctrl+shift+b': 'app:toggleBrief' as const }
 47          : {}),
 48        'ctrl+shift+o': 'app:toggleTeammatePreview',
 49        'ctrl+r': 'history:search',
 50        // File navigation. cmd+ bindings only fire on kitty-protocol terminals;
 51        // ctrl+shift is the portable fallback.
 52        ...(feature('QUICK_SEARCH')
 53          ? {
 54              'ctrl+shift+f': 'app:globalSearch' as const,
 55              'cmd+shift+f': 'app:globalSearch' as const,
 56              'ctrl+shift+p': 'app:quickOpen' as const,
 57              'cmd+shift+p': 'app:quickOpen' as const,
 58            }
 59          : {}),
 60        ...(feature('TERMINAL_PANEL') ? { 'meta+j': 'app:toggleTerminal' } : {}),
 61      },
 62    },
 63    {
 64      context: 'Chat',
 65      bindings: {
 66        escape: 'chat:cancel',
 67        // ctrl+x chord prefix avoids shadowing readline editing keys (ctrl+a/b/e/f/...).
 68        'ctrl+x ctrl+k': 'chat:killAgents',
 69        [MODE_CYCLE_KEY]: 'chat:cycleMode',
 70        'meta+p': 'chat:modelPicker',
 71        'meta+o': 'chat:fastMode',
 72        'meta+t': 'chat:thinkingToggle',
 73        enter: 'chat:submit',
 74        up: 'history:previous',
 75        down: 'history:next',
 76        // Editing shortcuts (defined here, migration in progress)
 77        // Undo has two bindings to support different terminal behaviors:
 78        // - ctrl+_ for legacy terminals (send \x1f control char)
 79        // - ctrl+shift+- for Kitty protocol (sends physical key with modifiers)
 80        'ctrl+_': 'chat:undo',
 81        'ctrl+shift+-': 'chat:undo',
 82        // ctrl+x ctrl+e is the readline-native edit-and-execute-command binding.
 83        'ctrl+x ctrl+e': 'chat:externalEditor',
 84        'ctrl+g': 'chat:externalEditor',
 85        'ctrl+s': 'chat:stash',
 86        // Image paste shortcut (platform-specific key defined above)
 87        [IMAGE_PASTE_KEY]: 'chat:imagePaste',
 88        ...(feature('MESSAGE_ACTIONS')
 89          ? { 'shift+up': 'chat:messageActions' as const }
 90          : {}),
 91        // Voice activation (hold-to-talk). Registered so getShortcutDisplay
 92        // finds it without hitting the fallback analytics log. To rebind,
 93        // add a voice:pushToTalk entry (last wins); to disable, use /voice
 94        // — null-unbinding space hits a pre-existing useKeybinding.ts trap
 95        // where 'unbound' swallows the event (space dead for typing).
 96        ...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {}),
 97      },
 98    },
 99    {
100      context: 'Autocomplete',
101      bindings: {
102        tab: 'autocomplete:accept',
103        escape: 'autocomplete:dismiss',
104        up: 'autocomplete:previous',
105        down: 'autocomplete:next',
106      },
107    },
108    {
109      context: 'Settings',
110      bindings: {
111        // Settings menu uses escape only (not 'n') to dismiss
112        escape: 'confirm:no',
113        // Config panel list navigation (reuses Select actions)
114        up: 'select:previous',
115        down: 'select:next',
116        k: 'select:previous',
117        j: 'select:next',
118        'ctrl+p': 'select:previous',
119        'ctrl+n': 'select:next',
120        // Toggle/activate the selected setting (space only — enter saves & closes)
121        space: 'select:accept',
122        // Save and close the config panel
123        enter: 'settings:close',
124        // Enter search mode
125        '/': 'settings:search',
126        // Retry loading usage data (only active on error)
127        r: 'settings:retry',
128      },
129    },
130    {
131      context: 'Confirmation',
132      bindings: {
133        y: 'confirm:yes',
134        n: 'confirm:no',
135        enter: 'confirm:yes',
136        escape: 'confirm:no',
137        // Navigation for dialogs with lists
138        up: 'confirm:previous',
139        down: 'confirm:next',
140        tab: 'confirm:nextField',
141        space: 'confirm:toggle',
142        // Cycle modes (used in file permission dialogs and teams dialog)
143        'shift+tab': 'confirm:cycleMode',
144        // Toggle permission explanation in permission dialogs
145        'ctrl+e': 'confirm:toggleExplanation',
146        // Toggle permission debug info
147        'ctrl+d': 'permission:toggleDebug',
148      },
149    },
150    {
151      context: 'Tabs',
152      bindings: {
153        // Tab cycling navigation
154        tab: 'tabs:next',
155        'shift+tab': 'tabs:previous',
156        right: 'tabs:next',
157        left: 'tabs:previous',
158      },
159    },
160    {
161      context: 'Transcript',
162      bindings: {
163        'ctrl+e': 'transcript:toggleShowAll',
164        'ctrl+c': 'transcript:exit',
165        escape: 'transcript:exit',
166        // q — pager convention (less, tmux copy-mode). Transcript is a modal
167        // reading view with no prompt, so q-as-literal-char has no owner.
168        q: 'transcript:exit',
169      },
170    },
171    {
172      context: 'HistorySearch',
173      bindings: {
174        'ctrl+r': 'historySearch:next',
175        escape: 'historySearch:accept',
176        tab: 'historySearch:accept',
177        'ctrl+c': 'historySearch:cancel',
178        enter: 'historySearch:execute',
179      },
180    },
181    {
182      context: 'Task',
183      bindings: {
184        // Background running foreground tasks (bash commands, agents)
185        // In tmux, users must press ctrl+b twice (tmux prefix escape)
186        'ctrl+b': 'task:background',
187      },
188    },
189    {
190      context: 'ThemePicker',
191      bindings: {
192        'ctrl+t': 'theme:toggleSyntaxHighlighting',
193      },
194    },
195    {
196      context: 'Scroll',
197      bindings: {
198        pageup: 'scroll:pageUp',
199        pagedown: 'scroll:pageDown',
200        wheelup: 'scroll:lineUp',
201        wheeldown: 'scroll:lineDown',
202        'ctrl+home': 'scroll:top',
203        'ctrl+end': 'scroll:bottom',
204        // Selection copy. ctrl+shift+c is standard terminal copy.
205        // cmd+c only fires on terminals using the kitty keyboard
206        // protocol (kitty/WezTerm/ghostty/iTerm2) where the super
207        // modifier actually reaches the pty — inert elsewhere.
208        // Esc-to-clear and contextual ctrl+c are handled via raw
209        // useInput so they can conditionally propagate.
210        'ctrl+shift+c': 'selection:copy',
211        'cmd+c': 'selection:copy',
212      },
213    },
214    {
215      context: 'Help',
216      bindings: {
217        escape: 'help:dismiss',
218      },
219    },
220    // Attachment navigation (select dialog image attachments)
221    {
222      context: 'Attachments',
223      bindings: {
224        right: 'attachments:next',
225        left: 'attachments:previous',
226        backspace: 'attachments:remove',
227        delete: 'attachments:remove',
228        down: 'attachments:exit',
229        escape: 'attachments:exit',
230      },
231    },
232    // Footer indicator navigation (tasks, teams, diff, loop)
233    {
234      context: 'Footer',
235      bindings: {
236        up: 'footer:up',
237        'ctrl+p': 'footer:up',
238        down: 'footer:down',
239        'ctrl+n': 'footer:down',
240        right: 'footer:next',
241        left: 'footer:previous',
242        enter: 'footer:openSelected',
243        escape: 'footer:clearSelection',
244      },
245    },
246    // Message selector (rewind dialog) navigation
247    {
248      context: 'MessageSelector',
249      bindings: {
250        up: 'messageSelector:up',
251        down: 'messageSelector:down',
252        k: 'messageSelector:up',
253        j: 'messageSelector:down',
254        'ctrl+p': 'messageSelector:up',
255        'ctrl+n': 'messageSelector:down',
256        'ctrl+up': 'messageSelector:top',
257        'shift+up': 'messageSelector:top',
258        'meta+up': 'messageSelector:top',
259        'shift+k': 'messageSelector:top',
260        'ctrl+down': 'messageSelector:bottom',
261        'shift+down': 'messageSelector:bottom',
262        'meta+down': 'messageSelector:bottom',
263        'shift+j': 'messageSelector:bottom',
264        enter: 'messageSelector:select',
265      },
266    },
267    // PromptInput unmounts while cursor active — no key conflict.
268    ...(feature('MESSAGE_ACTIONS')
269      ? [
270          {
271            context: 'MessageActions' as const,
272            bindings: {
273              up: 'messageActions:prev' as const,
274              down: 'messageActions:next' as const,
275              k: 'messageActions:prev' as const,
276              j: 'messageActions:next' as const,
277              // meta = cmd on macOS; super for kitty keyboard-protocol — bind both.
278              'meta+up': 'messageActions:top' as const,
279              'meta+down': 'messageActions:bottom' as const,
280              'super+up': 'messageActions:top' as const,
281              'super+down': 'messageActions:bottom' as const,
282              // Mouse selection extends on shift+arrow (ScrollKeybindingHandler:573) when present —
283              // correct layered UX: esc clears selection, then shift+↑ jumps.
284              'shift+up': 'messageActions:prevUser' as const,
285              'shift+down': 'messageActions:nextUser' as const,
286              escape: 'messageActions:escape' as const,
287              'ctrl+c': 'messageActions:ctrlc' as const,
288              // Mirror MESSAGE_ACTIONS. Not imported — would pull React/ink into this config module.
289              enter: 'messageActions:enter' as const,
290              c: 'messageActions:c' as const,
291              p: 'messageActions:p' as const,
292            },
293          },
294        ]
295      : []),
296    // Diff dialog navigation
297    {
298      context: 'DiffDialog',
299      bindings: {
300        escape: 'diff:dismiss',
301        left: 'diff:previousSource',
302        right: 'diff:nextSource',
303        up: 'diff:previousFile',
304        down: 'diff:nextFile',
305        enter: 'diff:viewDetails',
306        // Note: diff:back is handled by left arrow in detail mode
307      },
308    },
309    // Model picker effort cycling (ant-only)
310    {
311      context: 'ModelPicker',
312      bindings: {
313        left: 'modelPicker:decreaseEffort',
314        right: 'modelPicker:increaseEffort',
315      },
316    },
317    // Select component navigation (used by /model, /resume, permission prompts, etc.)
318    {
319      context: 'Select',
320      bindings: {
321        up: 'select:previous',
322        down: 'select:next',
323        j: 'select:next',
324        k: 'select:previous',
325        'ctrl+n': 'select:next',
326        'ctrl+p': 'select:previous',
327        enter: 'select:accept',
328        escape: 'select:cancel',
329      },
330    },
331    // Plugin dialog actions (manage, browse, discover plugins)
332    // Navigation (select:*) uses the Select context above
333    {
334      context: 'Plugin',
335      bindings: {
336        space: 'plugin:toggle',
337        i: 'plugin:install',
338      },
339    },
340  ]