Gives Claude Code's vim mode a bar cursor in INSERT and a block in NORMAL and VISUAL, and a Ctrl+C that only clears the prompt

A Claude Code mod for its vim mode ("editorMode": "vim"). It gives the prompt a cursor that shows the mode, and a Ctrl+C that only clears what you typed:
Ctrl+U and Ctrl+D scroll the chat half a page. That part needs no mod, only the keybindings below.
This mod is for Claude Code running in a terminal. In the Desktop app, VS Code or the web it does nothing.
"editorMode": "vim" in settings.json, or /config. Outside vim mode the mod leaves the cursor alone, and if you turn vim mode off mid-session the terminal's own cursor comes back.-- INSERT -- label shown below the prompt. The mod reads the mode from it, so with "hideVimModeIndicator": true in your statusLine setting it can't tell the modes apart and leaves the cursor alone.The mod can't change your settings or keybindings, so add these yourself.
settings.json, let Claude Code show the terminal's real cursor. Without it Claude Code hides the cursor and draws its own, so the shapes never show. It applies when Claude Code starts. {
"env": { "CLAUDE_CODE_NATIVE_CURSOR": "1" }
}
keybindings.json, next to settings.json, bind the keys: [
{ "context": "Scroll", "bindings": { "ctrl+u": "scroll:halfPageUp", "ctrl+d": "scroll:halfPageDown" } },
{ "context": "Chat", "bindings": { "ctrl+c": "command:vim-ux-clear" } }
]
Claude Code logs "Cannot be rebound … (hardcoded)" for ctrl+c and ctrl+d, but the bindings work.
claude --plugin-dir path/to/vim-ux
With the keybindings above:
d0 does that in NORMAL./exit and /quit are the ways out.If you turn the mod off but keep the Ctrl+C binding, Ctrl+C prints Unknown command: /vim-ux-clear.
command: binding takes the key first.Scroll keybindings are for.hooks/register.ts 52 lines1import type { Register } from 'claude-code'
2
3// DECSCUSR, steady shapes: a bar in INSERT, a block in NORMAL and VISUAL, and 0 for the terminal's own.
4const BAR = '\x1b[6 q'
5const BLOCK = '\x1b[2 q'
6const DEFAULT = '\x1b[0 q'
7const TTY = '/dev/tty'
8
9const CLEAR = 'vim-ux-clear'
10
11export const register: Register = on => {
12 // The shape last written, '' for the terminal's own: shapes are written only when they change.
13 let shape = ''
14
15 on('session.start', async ($, e, next) => {
16 // Bound to Ctrl+C in keybindings.json; `immediate` runs it at once while a turn is in flight.
17 await $.command.register({ name: CLEAR, description: 'Clears the prompt', immediate: true })
18 // Draw the prompt hint again, so a fresh load reads the mode without waiting for a key.
19 $.ui.invalidate('ui.render')
20 return next(e)
21 })
22
23 on('command.describe', { command: CLEAR }, (_$, e, next) => next({ ...e, isHidden: true }))
24
25 // Answering with no text leaves no transcript row, and the turn runs on: only Esc stops it.
26 on('command.run', { command: CLEAR }, async $ => {
27 await $.prompt.fill({ text: '', mode: 'replace' })
28 return {}
29 })
30
31 // The hint line is the only place the vim mode shows: it holds `-- INSERT --` or `-- VISUAL --`
32 // among its other parts, and no label in NORMAL. With statusLine.hideVimModeIndicator the label
33 // moves to the status line, out of a mod's reach. Settings are read on every draw, since /config
34 // can change them mid-session; when the mode can't be read, the terminal gets its own cursor back.
35 on('ui.render', { component: 'PromptHint', surface: 'terminal' }, async ($, e, next) => {
36 const { editorMode, statusLine } = await $.settings.read()
37 const isLabelHidden = (statusLine as { hideVimModeIndicator?: unknown } | undefined)?.hideVimModeIndicator === true
38 const want = editorMode !== 'vim' || isLabelHidden ? '' : e.props.hint.includes('-- INSERT --') ? BAR : BLOCK
39 if (want !== shape) {
40 shape = want
41 await $.fs.write(TTY, want || DEFAULT)
42 }
43 return next(e)
44 })
45
46 // After /clear or a resume the prompt stays on screen, so its shape stays too.
47 on('session.end', async ($, e, next) => {
48 if (shape && e.reason !== 'clear' && e.reason !== 'resume') await $.fs.write(TTY, DEFAULT)
49 return next(e)
50 })
51}
52