zsh-vim-mode
June 12, 2026 ยท View on GitHub
A fork of oh-my-zsh's vi-mode plugin,
focused on making prompt redraws on mode changes reliable and well-behaved.
๐ Rendered documentation: docs.georgeharker.com/zsh-vim-mode
The public function vi_mode_prompt_info and the vi key bindings are kept, but
all configuration is via zstyle under the :zsh-vim-mode:* context rather
than environment variables (see Configuration).
What's different from omz vi-mode
1. Mode changes redraw the prompt by default
Upstream only redrew the prompt if it could detect โ via an exact substring
match for '$(vi_mode_prompt_info)' in $PS1/$RPS1 โ that you were showing
the mode in the prompt. That detection is brittle (themes that build the prompt
in precmd, store the indicator in a variable, or call the function any other
way all slip past it), so redraws frequently failed to happen.
Here, the redraw style defaults to always: every switch between insert /
normal / visual issues zle reset-prompt; zle -R, so the prompt indicator and
cursor always reflect the current mode.
zle reset-promptre-expands$PROMPT/$RPROMPTbut does not re-runprecmd. For the common pattern of computing expensive things (git status, etc.) inprecmdand storing them in a variable, the redraw is cheap. It is only costly if you embed$(slow-command)directly inside$PROMPT.
2. Order-independent; no fighting over contested widgets
Upstream installed its ZLE hooks with raw zle -N zle-keymap-select,
zle-line-init, and zle-line-finish. Those last two are heavily contested:
oh-my-posh decorates zle-line-init (it runs .recursive-edit there),
and zsh-autosuggestions / fast-syntax-highlighting wrap it as well.
When several wrapping schemes meet on zle-line-init in the wrong order, the
wrappers chain into each other and zsh aborts with:
_omp_decorated_zle-line-init: maximum nested function level reached; increase FUNCNEST?
This plugin sidesteps that entirely. The only zle widgets it hooks are
keymap-select โ the actual mode-change event that drives the redraw โ and
line-pre-redraw (which re-asserts the cursor shape after every widget), both
via add-zle-hook-widget (nothing contested wraps those). Everything omz did in
the zle-line-init / zle-line-finish widgets (reset to insert mode, cursor
shape, keypad mode) is instead done in precmd / preexec, registered with
add-zsh-hook. Those are plain hook arrays with no wrap/absorb semantics, so
the plugin composes with everything and does not depend on load order โ it
works correctly whether it loads before or after oh-my-posh and the rest.
3. Smarter (optional) auto-detection
If you set zstyle ':zsh-vim-mode:' redraw auto, the plugin redraws only when
the prompt actually contains the mode indicator โ matching the loose substring
vi_mode_prompt_info rather than the exact $(...) form, so it still fires
when a theme calls the function indirectly.
Install
Manual
git clone https://github.com/georgeharker/zsh-vim-mode ~/.zsh/zsh-vim-mode
echo 'source ~/.zsh/zsh-vim-mode/zsh-vim-mode.plugin.zsh' >> ~/.zshrc
Load order does not matter โ source it before or after oh-my-posh, zsh-autosuggestions, fast-syntax-highlighting, etc.
oh-my-zsh
Clone into your custom plugins dir and replace vi-mode in your plugin list:
git clone https://github.com/georgeharker/zsh-vim-mode "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-vim-mode"
# plugins=(... zsh-vim-mode) # remove the stock `vi-mode`
Loading both the stock vi-mode and this plugin is not recommended; this one
replaces it.
Configuration
Everything is configured with zstyle, under the :zsh-vim-mode:* context.
Set these before sourcing the plugin (per-mode indicator/cursor and the
set-cursor/redraw/redraw-hooks styles are read live, so they can also be
changed later; insert-keymap and clipboard are read once at load).
Global โ context :zsh-vim-mode:
| style | values | default | meaning |
|---|---|---|---|
set-cursor | bool | no | change the cursor shape per mode |
redraw | always | auto | never | always | when to redraw the prompt on a mode change (auto = only if the prompt contains vi_mode_prompt_info) |
insert-keymap | viins | emacs | viins | which keymap insert mode uses (see below) |
clipboard | bool | yes | copy/paste yank/put to the system clipboard |
redraw-hooks | function names | โ | functions run just before each mode-change redraw (see render-baked prompts) |
zstyle ':zsh-vim-mode:' set-cursor yes
zstyle ':zsh-vim-mode:' redraw always
zstyle ':zsh-vim-mode:' insert-keymap emacs
Per mode โ context :zsh-vim-mode:<mode>
<mode> is one of normal, insert, visual, visual-line, op
(operator-pending).
| style | meaning |
|---|---|
indicator | string emitted by vi_mode_prompt_info; '' shows nothing |
cursor | block, line/bar, underline, any blink-*, or a raw DECSCUSR 0โ6 |
zstyle ':zsh-vim-mode:normal' indicator '[Normal]'
zstyle ':zsh-vim-mode:insert' indicator '' # blank insert indicator
zstyle ':zsh-vim-mode:visual' indicator '[Visual]'
zstyle ':zsh-vim-mode:visual-line' indicator '[V-Line]'
zstyle ':zsh-vim-mode:normal' cursor block
zstyle ':zsh-vim-mode:insert' cursor line
Indicator defaults are [Normal] [Ins] [Visual] [V-Line] (and op
follows normal). Indicator values go through prompt expansion in a zsh
$PROMPT/$RPROMPT, so %F{โฆ}/%B work there; for oh-my-posh use plain text
or omp markup, since the value is rendered by the omp binary.
Replace mode (
R) currently reports as insert โ it isn't a distinct keymap, so the prompt can't tell it apart without wrapping thevi-replacewidget.
Insert keymap
insert-keymap controls what insert mode actually is:
viins(default) โ the conventional, sparse vi insert keymap. The plugin re-adds the common readline keys it drops (^A/^E,^P/^N,^R/^S,^W,^?/^H).emacsโ insert mode is the emacs keymap. You get the full readline editing set (arrows,Alt-word motion,^A/^E/^K/^U/^Y, history search, โฆ) for free and live: anything bound to the emacs keymap by you or another plugin is active in insert mode, regardless of load order.ESCdrops to vi command mode (it coexists with theAlt/meta keys via$KEYTIMEOUT). Normal/visual/operator modes are unchanged.
zstyle ':zsh-vim-mode:' insert-keymap emacs
Showing the mode in your prompt
The plugin core is prompt-agnostic: on every mode change it sets VI_KEYMAP,
runs the functions in the redraw-hooks style, and issues
zle reset-prompt; zle -R. How the indicator reaches the screen depends on
which kind of prompt you use.
Prompt-expansion prompts (oh-my-zsh themes, hand-rolled $PROMPT)
These embed $(vi_mode_prompt_info) in $PROMPT/$RPROMPT, which zsh
re-expands on every reset-prompt. Nothing extra is needed โ the redraw
the plugin already does updates the indicator. If neither $RPS1 nor $RPROMPT
is set when the plugin loads, it defaults RPS1='$(vi_mode_prompt_info)'. To
place it yourself:
PROMPT="$PROMPT"'$(vi_mode_prompt_info)'
RPROMPT='$(vi_mode_prompt_info)'"$RPROMPT"
The single quotes matter: they defer evaluation so it's recomputed each redraw.
Render-baked prompts (oh-my-posh, etc.)
These render once per precmd into a static string, so reset-prompt alone
redraws the old mode. oh-my-posh is the common case: its indicator comes from
an env var (e.g. $VIMODE) that the oh-my-posh binary reads at render time.
First, add a segment to your oh-my-posh theme that renders the env var (a
text segment reading {{ .Env.VIMODE }} โ put it in whichever block you like;
the right/rprompt block is common):
[[blocks.segments]]
template = '{{ .Env.VIMODE }}'
foreground = 'p:white'
background = 'transparent'
type = 'text'
style = 'plain'
Then keep it live: register a function in the redraw-hooks style. The plugin
calls each โ inside the ZLE widget, right before it issues reset-prompt โ so
you re-render omp there, then the plugin's reset-prompt displays the result:
function set_poshcontext() { export VIMODE="$(vi_mode_prompt_info)" }
function _vimode_omp_render() {
set_poshcontext # refresh $VIMODE for the new mode
RPROMPT=$(_omp_get_prompt right) # re-render the block that shows it
}
zstyle ':zsh-vim-mode:' redraw-hooks _vimode_omp_render
_omp_get_prompt is the least-invasive render omp exposes: it only reads
omp's cached state (status, execution-time, โฆ) and prints, so it doesn't reset
those segments โ it's the same call omp's tooltip feature uses. Render only the
block carrying the indicator (right here).
The simpler zstyle ':zsh-vim-mode:' redraw-hooks _omp_precmd also works โ it
re-runs omp's whole precmd render (and refreshes VIMODE via set_poshcontext
itself) โ but it re-captures $? and execution time, so the status and timing
segments reset on every mode change. Prefer _omp_get_prompt.
Either way it runs on every mode change, so keep the render cheap. Works whether or not oh-my-posh streaming is enabled; streaming only affects the left prompt.
Cursor styles
Set set-cursor and a per-mode cursor (defaults shown):
zstyle ':zsh-vim-mode:' set-cursor yes
zstyle ':zsh-vim-mode:normal' cursor block # solid block
zstyle ':zsh-vim-mode:insert' cursor line # solid line (bar)
zstyle ':zsh-vim-mode:visual' cursor line
zstyle ':zsh-vim-mode:visual-line' cursor line
zstyle ':zsh-vim-mode:op' cursor block # operator-pending; follows normal
A cursor is a name โ block, line/bar, underline, or any blink-* โ
or a raw DECSCUSR number 0โ6 (0 default ยท 1 blink-block ยท 2 block ยท
3 blink-underline ยท 4 underline ยท 5 blink-line ยท 6 line). See
DECSCUSR.
Operator-pending (op) defaults to the normal cursor rather than a distinct
shape: ZLE doesn't reliably fire zle-keymap-select on the viopp โ vicmd
return, so a distinct oppend cursor tends to get stuck after e.g. dw. Set it
if you want one anyway.
Terminals that manage the cursor themselves
Some terminals ship a shell-integration feature that also drives the cursor
shape from the keymap โ e.g. Ghostty's cursor feature
(shell-integration-features includes cursor), and similar in others. That
makes two stampers fighting over one cursor: the terminal's reads the live
$KEYMAP directly, with no mode tracking, so it paints an insert bar on the
main keymap that ZLE transiently selects during a prompt redraw โ exactly the
flicker-after-dw this plugin is built to avoid. Symptom: the cursor snaps back
to a bar in normal mode after operators (dw, x, p), then corrects on the
next keypress.
Pick one owner. To let this plugin own it (recommended โ it has per-mode shapes and tracks the real mode), disable the terminal's cursor feature:
- Ghostty โ add
no-cursortoshell-integration-featuresinconfig(e.g.shell-integration-features = no-cursor,sudo,title), then fully reload Ghostty โ config changes don't reach already-running shells/windows.
Or do the reverse: let the terminal own it and set zstyle ':zsh-vim-mode:' set-cursor no.
Key bindings
ESC or CTRL-[ enters normal mode. The plugin also binds:
vv(normal mode) โ edit the current command line in$EDITORctrl-p/ctrl-nโ previous / next historyctrl-r/ctrl-sโ incremental history search backward / forwardctrl-a/ctrl-eโ beginning / end of linectrl-h/ctrl-w/ctrl-?โ delete char / word / char before cursorv(normal mode) โ visual mode
Yank/delete/change copy to the system clipboard; p/P paste from it (unless
zstyle ':zsh-vim-mode:' clipboard no). This uses the clipcopy/clippaste
helpers โ oh-my-zsh provides them; on a manual install define your own (or the
integration silently no-ops).
Low $KEYTIMEOUT
A low $KEYTIMEOUT (< 15) makes multi-key bindings like vv hard to trigger.
Raise $KEYTIMEOUT, or rebind, e.g. bindkey -M vicmd 'V' edit-command-line.
License
MIT, inheriting from oh-my-zsh. See LICENSE.