SLOPSHOPPER

vim-cursor

Block cursor in vim normal mode, thin bar in insert mode

newspinnerprocess
v0.1.0MITupdated 2026-10-14nuTTeLLo/claude-mods/vim-cursor
A shopper browsing a rack in a slop shop
README

vim-cursor

Changes the terminal cursor with Claude Code's vim mode: a steady block in normal mode, a steady thin bar in insert mode.

Requirements

  • Claude Code in a terminal, with vim mode on (/config → Editor mode → vim).
  • A terminal that honours cursor-shape codes (DECSCUSR, ESC [ N q). Most do.
  • macOS or Linux (sh and ps).

Install

/plugin install vim-cursor --marketplace nuTTeLLo/claude-mods

Nothing to configure. To remove it: /plugin uninstall vim-cursor.

How it works

  • The mod API does not expose vim mode, so the mod reads the hint line under the prompt: it carries -- INSERT -- while in insert mode. Anything else counts as normal.
  • When the mode changes it writes the cursor-shape code to the terminal device, found by walking up the process tree to the first ancestor with a terminal (hooks have none).
  • On session end it resets the cursor to the terminal's default.

Limits

  • Visual mode, and a running turn (when the hint line is replaced), show the block.
  • Detection depends on the -- INSERT -- wording; a Claude Code release that changes it leaves the cursor a block.
  • Each mode switch runs one short sh, so the change lags the keypress slightly.
  • Does nothing in desktop or headless sessions.

Changing the shapes

Edit the codes at the top of hooks/register.ts: 1/2 block, 3/4 underline, 5/6 bar (odd blinks, even is steady).

Source 1 files
hooks/register.ts 70 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// DECSCUSR shapes: the terminal's own cursor, steady.
4const BLOCK = '2'
5const BAR = '6'
6const DEFAULT = '0'
7
8// Hooks run without a controlling terminal, so walk up to the first ancestor that has one.
9const FIND_TTY = `p=$PPID
10while [ -n "$p" ] && [ "$p" != 1 ]; do
11  t=$(ps -o tty= -p "$p" | tr -d ' ')
12  case "$t" in
13    ''|'??') p=$(ps -o ppid= -p "$p" | tr -d ' ') ;;
14    *) echo "/dev/$t"; exit 0 ;;
15  esac
16done
17exit 1`
18
19let tty: string | undefined
20let shape: string | undefined
21
22// The hint line under the prompt carries vim's `-- INSERT --` marker.
23export function shapeFor(hint: string): string {
24  return /insert/i.test(hint) ? BAR : BLOCK
25}
26
27async function findTty($: EngineInterface): Promise<string | undefined> {
28  if (tty !== undefined) return tty
29  try {
30    const ran = await $.process.run(['sh', '-c', FIND_TTY])
31    const found = ran.stdout.trim()
32    if (ran.exitCode === 0 && found.startsWith('/dev/')) tty = found
33  } catch {
34    // No terminal to write to (a desktop or headless session): the mod does nothing.
35  }
36
37  return tty
38}
39
40async function setShape($: EngineInterface, wanted: string): Promise<void> {
41  if (wanted === shape) return
42  const device = await findTty($)
43  if (device === undefined) return
44  shape = wanted
45  await $.process.run(['sh', '-c', 'printf "\\033[%s q" "$1" > "$2"', 'sh', wanted, device])
46}
47
48export const register: Register = on => {
49  on('session.start', async ($, e, next) => {
50    // Vim mode starts a prompt in insert.
51    void setShape($, BAR)
52
53    return next(e)
54  })
55
56  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
57    if (e.surface === 'terminal') {
58      void setShape($, shapeFor(e.props.hint))
59    }
60
61    return next(e)
62  })
63
64  on('session.end', async ($, e, next) => {
65    await setShape($, DEFAULT)
66
67    return next(e)
68  })
69}
70