Claude Code’s keyboard shortcuts are configurable through a single file: ~/.claude/keybindings.json. Run /keybindings to create or open it. Each entry maps a keystroke to an action written as namespace:action — chat:submit, app:toggleTodos, history:search — scoped to one of 19 contexts, and changes apply live without restarting the session.
That’s the mechanism in one paragraph. The part that trips people up is everything downstream of it: which of the 19 contexts a given shortcut actually lives in, why unbinding one chord doesn’t free up the key you expected, why vim mode ignores your keybindings file entirely for text editing, and which defaults quietly changed between versions because they collided with something else. Most write-ups reproduce the official shortcut list and stop. This one goes through the full context/action surface, the keystroke syntax rules that produce confusing edge cases, and configs for the friction points people actually hit — tmux’s Ctrl+B prefix, reclaiming Ctrl+X, and matching VS Code or vim conventions without breaking something else.
Keybindings vs. vim mode: they don’t share a config
This is the single most common confusion, so it’s worth settling first:
Keybindings (keybindings.json) | Vim mode (/config → Editor mode) | |
|---|---|---|
| What it controls | Component-level actions — submit, toggle todos, cycle permission mode | Text-input-level editing — cursor motion, NORMAL/INSERT/VISUAL modes |
| Config file | ~/.claude/keybindings.json | A setting, not a file — toggled via /config |
| Can you remap it? | Yes, per context | Only partially — vimInsertModeRemaps lets you map two-key INSERT-mode sequences (like jj → Escape) to leave INSERT mode; the vim motions themselves aren’t remappable here |
| Escape key behavior | Triggers chat:cancel in the Chat context | Switches INSERT → NORMAL; does not trigger chat:cancel |
? and / in vim NORMAL mode | N/A | ? opens the help menu, / opens history search — vim conventions, not keybinding actions |
They run side by side. Most Ctrl+key shortcuts still pass through vim mode to the keybinding system — it’s specifically the plain-letter motions (h/j/k/l, dd, etc.) and the Escape-to-NORMAL behavior that live outside keybindings.json entirely.
Set it up
/keybindings
This creates ~/.claude/keybindings.json if it doesn’t exist and opens it. The file is a single object with a bindings array:
{
"$schema": "https://www.schemastore.org/claude-code-keybindings.json",
"$docs": "https://code.claude.com/docs/en/keybindings",
"bindings": [
{
"context": "Chat",
"bindings": {
"ctrl+e": "chat:externalEditor",
"ctrl+u": null
}
}
]
}
$schema is optional but worth keeping — it gets you autocomplete and inline validation in any editor that reads JSON Schema (VS Code does this natively). $docs is just a comment field. The example above rebinds Ctrl+E to open your $EDITOR from the chat input, and unbinds the default Ctrl+U.
Changes save and apply immediately. No restart, no /reload.
The 19 contexts
Each block in bindings targets exactly one context — the UI state a shortcut is active in. A binding for Chat does nothing while a confirmation dialog is focused, and vice versa.
| Context | Active when |
|---|---|
Global | Always — app-level actions like interrupt and exit |
Chat | Typing in the main prompt input |
Autocomplete | The /command or @file autocomplete menu is open |
Settings | /config settings panel is open |
Confirmation | A permission or confirmation dialog is showing |
Tabs | Tab-based navigation components |
Help | The help menu (?) is visible |
Transcript | Full transcript viewer (Ctrl+O) is open |
HistorySearch | History search mode (Ctrl+R) is active |
Task | A background task is running |
ThemePicker | The theme picker dialog is open |
Attachments | Navigating between attached images in a select dialog |
Footer | Footer indicators (tasks, teams, diff, artifacts) have focus |
MessageSelector | The rewind/summarize message-picker dialog is open |
DiffDialog | The diff viewer is open |
ModelPicker | The model/effort picker is open |
Select | Generic select/list components (used by several dialogs) |
Plugin | The plugin browse/discover/manage dialog is open |
Scroll | Conversation scrolling and text selection, fullscreen rendering only |
A Doctor context existed for the /doctor diagnostics screen before v2.1.205 and no longer appears in the current schema — if you’re on an older build and see references to a doctor:fix action, that’s why it’s gone from current docs.
Keystroke syntax
Modifiers
ctrl+k Ctrl + K
shift+tab Shift + Tab
meta+p Option+P on macOS, Alt+P elsewhere
ctrl+shift+c Stack multiple modifiers with +
Accepted modifier names: ctrl/control, shift, alt/opt/option/meta (Alt on Windows/Linux, Option on macOS), and cmd/command/super/win (Cmd on macOS, Windows key on Windows, Super on Linux). That last group only works in terminals reporting the Super modifier — Kitty keyboard protocol or xterm’s modifyOtherKeys mode. Most terminal emulators don’t send it. If a binding needs to work everywhere, use ctrl or meta, not cmd.
Uppercase letters imply Shift — except with a modifier
A bare K in a binding means Shift+K — this is what makes vim-style bindings (lowercase and uppercase meaning different things) work at all. But once you add a modifier, the uppercase stops mattering: ctrl+K and ctrl+k are identical. This asymmetry catches people who assume the rule is consistent everywhere in the file.
Chords
Space-separated keystrokes fire in sequence:
ctrl+k ctrl+s Press Ctrl+K, release, then Ctrl+S
Special keys
escape/esc, enter/return, tab, space, up/down/left/right, backspace/delete.
Unbinding, and why it doesn’t always free the key you expect
Set an action to null to remove a default:
{
"bindings": [
{ "context": "Chat", "bindings": { "ctrl+s": null } }
]
}
Chords make this less obvious than it looks. Claude Code ships three default Ctrl+X chords spread across two contexts: ctrl+x ctrl+k and ctrl+x ctrl+e in Chat, plus ctrl+x ctrl+b in Task. Unbind only one and Ctrl+X still enters “chord-wait mode” the moment you press it, because the prefix is still reserved by the others. To actually reclaim Ctrl+X as a single-key binding, every chord sharing that prefix has to go, across every context that defines one:
{
"bindings": [
{
"context": "Task",
"bindings": { "ctrl+x ctrl+b": null }
},
{
"context": "Chat",
"bindings": {
"ctrl+x ctrl+k": null,
"ctrl+x ctrl+e": null,
"ctrl+x": "chat:newline"
}
}
]
}
Note the ordering doesn’t matter across context blocks, but all three nulls have to exist somewhere in the file for ctrl+x to stop waiting for a second key.
Reserved — can’t be rebound at all
| Shortcut | Reason |
|---|---|
Ctrl+C | Hardcoded interrupt/cancel |
Ctrl+D | Hardcoded exit |
Ctrl+M | Terminals send the same code as Enter (CR), so they’re indistinguishable |
| Caps Lock | Never delivered to terminal apps at all |
Terminal multiplexer conflicts
| Shortcut | Conflicts with |
|---|---|
Ctrl+B | tmux prefix — press twice to send through to Claude Code |
Ctrl+A | GNU screen prefix |
Ctrl+Z | Unix SIGTSTP (process suspend) |
If you live in tmux, the default task:background binding (Ctrl+B, plus a Ctrl+X Ctrl+B chord added in v2.1.169 specifically to route around this) already has a workaround built in — use the chord instead of fighting your tmux prefix key.
Ready-to-use configs
Reclaim Ctrl+S from the terminal’s flow-control freeze. Some terminals still intercept Ctrl+S for XOFF before Claude Code sees it. If your chat:stash binding (default Ctrl+S) seems to do nothing, move it:
{
"bindings": [
{ "context": "Chat", "bindings": { "ctrl+s": null, "ctrl+j": "chat:stash" } }
]
}
(Swap chat:newline’s default off Ctrl+J first if you use that one — check what’s already bound before overwriting.)
VS Code-style save-and-continue muscle memory, mapping Cmd+K behavior explicitly instead of relying on the default:
{
"bindings": [
{
"context": "Chat",
"bindings": { "cmd+k": "chat:clearScreen", "ctrl+g": "chat:externalEditor" }
}
]
}
Vim-style navigation in the message selector (rewind/summarize dialog) — already the default, but explicit if you’ve unbound it elsewhere and want it back:
{
"bindings": [
{
"context": "MessageSelector",
"bindings": {
"j": "messageSelector:down",
"k": "messageSelector:up",
"ctrl+n": "messageSelector:down",
"ctrl+p": "messageSelector:up"
}
}
]
}
Turn off the accidental-Ctrl+D-exits-Claude-Code risk as much as the reserved-shortcut rule allows — you can’t unbind Ctrl+D itself (it’s hardcoded), but you can slow down accidental history-search exits that people often conflate with it by confirming historySearch:cancel requires a deliberate key:
{
"bindings": [
{ "context": "HistorySearch", "bindings": { "ctrl+c": "historySearch:cancel" } }
]
}
Action reference by context
The full surface, condensed. Defaults shown are what ships out of the box — anything you rebind overrides these.
Global & history actions
| Action | Default | Description |
|---|---|---|
app:interrupt | Ctrl+C | Cancel current operation |
app:exit | Ctrl+D | Exit — press twice within 800ms to confirm |
app:redraw | unbound | Force terminal redraw |
app:toggleTodos | Ctrl+T | Toggle Claude’s to-do checklist (not the /tasks background view) |
app:toggleTranscript | Ctrl+O | Toggle verbose transcript |
history:search | Ctrl+R | Open history search |
history:previous / history:next | Up / Down | Navigate command history |
Chat actions
| Action | Default | Description |
|---|---|---|
chat:cancel | Escape | Cancel current input |
chat:clearInput | Ctrl+L | Redraw screen, keep input; press twice within 2s in fullscreen to run /clear |
chat:clearScreen | Cmd+K | Same double-tap /clear behavior in fullscreen |
chat:killAgents | Ctrl+X Ctrl+K | Stop all running background subagents in this session |
chat:cycleMode | Shift+Tab | Cycle permission modes |
chat:modelPicker | Meta+P | Open model picker |
chat:fastMode | Meta+O | Toggle fast mode |
chat:thinkingToggle | Meta+T | Toggle extended thinking |
chat:submit | Enter | Submit message |
chat:newline | Ctrl+J | Insert newline without submitting |
chat:undo | Ctrl+_, Ctrl+Shift+- | Undo last action |
chat:externalEditor | Ctrl+G, Ctrl+X Ctrl+E | Open input in $EDITOR |
chat:stash | Ctrl+S | Stash current prompt |
chat:imagePaste | Ctrl+V (Alt+V on Windows/WSL) | Paste image from clipboard |
Windows without VT mode (Node < 24.2.0/22.17.0, Bun < 1.2.23) falls back to Meta+M for chat:cycleMode instead of Shift+Tab.
Autocomplete, Confirmation & Permission actions
| Action | Default | Description |
|---|---|---|
autocomplete:accept | Tab | Accept suggestion |
autocomplete:dismiss | Escape | Dismiss menu |
autocomplete:previous / :next | Up / Down | Navigate suggestions |
confirm:yes | Y, Enter | Confirm |
confirm:no | N, Escape | Decline |
confirm:previous / :next | Up / Down | Navigate options |
confirm:nextField | Tab | Next field |
confirm:toggle | Space | Toggle selection |
confirm:cycleMode | Shift+Tab | Cycle permission modes |
confirm:toggleExplanation | Ctrl+E | Toggle model-generated explanation on Bash/PowerShell permission prompts |
permission:toggleDebug | unbound | Toggle permission debug info — the old Ctrl+D default was removed in v2.1.146 because it shadowed app:exit |
Transcript, History Search & Task actions
| Action | Default | Description |
|---|---|---|
transcript:toggleShowAll | Ctrl+E | Toggle show-all (classic renderer only) |
transcript:exit | q, Ctrl+C, Escape | Exit transcript view |
historySearch:next | Ctrl+R | Next match (classic renderer) |
historySearch:accept | Escape, Tab | Accept selection |
historySearch:cancel | Ctrl+C | Cancel search |
historySearch:execute | Enter | Run selected command |
historySearch:cycleScope | Ctrl+S | Cycle session/project/everywhere scope — fullscreen only |
task:background | Ctrl+B, Ctrl+X Ctrl+B | Background the current task; the chord (v2.1.169+) exists specifically to dodge the tmux prefix |
Dialog navigation: Theme, Help, Tabs, Attachments, Footer, MessageSelector, DiffDialog, ModelPicker, Select, Plugin, Settings
| Action | Default | Description |
|---|---|---|
theme:toggleSyntaxHighlighting | Ctrl+T | Toggle syntax highlighting in theme picker |
help:dismiss | Escape | Close help menu |
tabs:next / :previous | Tab/Right, Shift+Tab/Left | Move between tabs |
attachments:next / :previous | Right / Left | Navigate attached images |
attachments:remove | Backspace, Delete | Remove selected attachment |
attachments:exit | Down, Escape | Exit attachment navigation |
footer:next / :previous | Right / Left | Move between footer items |
footer:openSelected | Enter | Open selected footer item |
footer:dismiss | Backspace, Delete | Dismiss the selected artifact link from the footer — requires v2.1.217+ |
messageSelector:up / :down | Up/K/Ctrl+P, Down/J/Ctrl+N | Navigate rewind/summarize message list |
messageSelector:top / :bottom | Ctrl+Up/Shift+Up/Meta+Up/Shift+K, Ctrl+Down/Shift+Down/Meta+Down/Shift+J | Jump to top/bottom |
diff:dismiss | Escape | Close diff viewer (or return to file list from detail view) |
diff:previousFile / :nextFile | Up/K, Down/J | Navigate files in the diff list |
diff:back | unbound | Go back — the old default of Left in detail view was removed in v2.1.203 |
modelPicker:decreaseEffort / :increaseEffort | Left / Right | Adjust effort level |
modelPicker:thisSessionOnly | s | Apply the highlighted model to this session only |
select:next / :previous | Down/J/Ctrl+N, Up/K/Ctrl+P | Generic list navigation |
select:accept / :cancel | Enter, Escape | Accept/cancel |
plugin:toggle | Space | Toggle plugin selection |
plugin:install | I | Install selected plugins |
plugin:favorite | F | Pin plugin near the top of the Installed tab |
settings:search | / | Enter search mode |
settings:retry | R | Retry usage-data load on error |
In Settings, select:accept and confirm:no are reused with different semantics: changes apply the instant you change a setting, so Escape closes the panel with edits already saved rather than reverting them.
Scroll actions (fullscreen rendering only)
| Action | Default | Description |
|---|---|---|
scroll:lineUp / :lineDown | unbound | One-line scroll — mouse wheel triggers these |
scroll:pageUp / :pageDown | PageUp / PageDown | Half-viewport scroll |
scroll:top / :bottom | Ctrl+Home / Ctrl+End | Jump to start/latest message (bottom re-enables auto-follow) |
scroll:halfPageUp / :halfPageDown | unbound | Same as page scroll, provided for vi-style rebinds |
scroll:fullPageUp / :fullPageDown | unbound | Full-viewport scroll |
selection:copy | Ctrl+Shift+C / Cmd+C | Copy selected text |
selection:extendLeft/Right/Up/Down | Shift+arrows | Extend text selection |
selection:extendLineStart / :extendLineEnd | Shift+Home / Shift+End | Extend to line boundaries |
The DiffDialog detail view separately binds pager-style scroll keys (PageUp/PageDown/Shift+Space+B/Space/G+Home/Shift+G+End) as part of its own context — those aren’t the same bindings as the Scroll context defaults above, even though the actions share names.
Validation and debugging
Claude Code validates the file on load and warns about:
- Parse errors (invalid JSON or structure)
- Invalid context names
- Reserved-shortcut conflicts (trying to rebind
Ctrl+C,Ctrl+D, etc.) - Terminal multiplexer conflicts
- Duplicate bindings within the same context
Warnings write to the debug log, not the main UI — start with claude --debug to actually see them.
Troubleshooting
My binding doesn’t fire at all.
Check the context first. A Chat-scoped binding is inert while a Confirmation dialog has focus — this is the most common cause, since the two look similar at a glance but are entirely separate contexts.
I unbound a chord and the prefix still waits for a second key.
You didn’t unbind every chord sharing that prefix. See the Ctrl+X example above — a prefix stays reserved as long as any active context still defines a chord on it.
Ctrl+D still exits even though I remapped permission:toggleDebug.
Ctrl+D for app:exit is hardcoded and reserved; it can’t be unbound regardless of what else you map to it in another context.
My binding works on Mac but not on Windows (or vice versa).
cmd/command/super/win only register in terminals that report the Super modifier (Kitty protocol, xterm modifyOtherKeys). Most Windows terminals and plain xterm don’t send it — rebind with ctrl or meta if cross-platform behavior matters.
Vim mode is on and my keybinding for a plain letter doesn’t do what I mapped.
Vim mode owns plain-letter input in Chat for motions and mode-switching — it isn’t routed through keybindings.json. Rebind vim’s own INSERT-mode-exit sequence via vimInsertModeRemaps instead, and keep keybindings.json changes to Ctrl+key combinations, which do still pass through.
FAQ
Do keybinding changes need a restart? No — the file is watched and reloaded automatically.
Can I have different keybindings per project?
No. Unlike settings.json, keybindings.json doesn’t have a project-level variant documented — it’s a single user-level file at ~/.claude/keybindings.json.
What happens if I bind the same key twice in one context?
Claude Code flags it as a duplicate-binding warning in the debug log (claude --debug); the behavior in that case is undefined enough that it’s worth just fixing the duplicate rather than relying on which one wins.
Why does ctrl+K behave the same as ctrl+k, but plain K means Shift+K?
Because the uppercase-implies-Shift rule only applies to bare keys with no modifier. The moment you add ctrl, alt, or another modifier, casing becomes purely stylistic.
Is there a way to see what’s currently bound without opening the JSON?
The in-app help menu (? outside vim mode, or ? in vim NORMAL mode) lists active shortcuts for the current context.
Browse how real projects configure settings.json, hooks, and permissions in our rules gallery.