SLOPSHOPPER

vim-ux

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

newspinnercommand
v0.2.0MITupdated 2026-10-12AH-Merii/claude-plugins/plugins/vim-ux
A shopper browsing a rack in a slop shop
README

vim-ux

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:

  • A steady bar cursor in INSERT, and a steady block in NORMAL and VISUAL. On exit the terminal's own cursor comes back.
  • Ctrl+C clears the prompt and does nothing else. It never exits and never stops a running turn; Esc stops a turn. While Claude is working you can clear a draft and the reply carries on.

Ctrl+U and Ctrl+D scroll the chat half a page. That part needs no mod, only the keybindings below.

Terminal only

This mod is for Claude Code running in a terminal. In the Desktop app, VS Code or the web it does nothing.

Requirements

  • Claude Code 2.1.289 or newer.
  • Vim mode on: "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.
  • The -- 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.
  • A terminal that draws DECSCUSR cursor shapes, such as Ghostty.

Setup

The mod can't change your settings or keybindings, so add these yourself.

  1. In 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" }
   }
  1. In 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.

Try it

claude --plugin-dir path/to/vim-ux

Defaults it overrides

With the keybindings above:

  • Ctrl+U no longer deletes back to the start of the line. d0 does that in NORMAL.
  • Ctrl+D no longer deletes the character under the cursor in INSERT, and no longer starts an exit.
  • Ctrl+C no longer exits, cancels the model picker or leaves the Ctrl+O view. Esc does those.
  • /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.

What a mod can't do

  • Colour the prompt by mode. A mod gets no event when the vim mode changes, and the only way to repaint the prompt, filling it again, moves the vim cursor. So the prompt keeps one colour in every mode.
  • Catch Ctrl+C or Ctrl+D itself. Claude Code handles exit on its own; only a command: binding takes the key first.
  • Scroll the chat. That is what the Scroll keybindings are for.
Source 1 files
hooks/register.ts 52 lines
1import 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