Adding Vi To Your Zsh
Posted October 1st, 2013
The Omnipresence Of Vim
Vim is the best editor you've ever used.
Vim emulation seems to be everywhere: Eclipse has Vrapper, Sublime has Vintage, Emacs has Evil. Vim bindings are so ubiquitous, you can even find them in desktop applications such as Chrome and web apps like Gmail. This is great. You are happy.
But do you vi while you zsh?
Zsh Line Editing
When you type characters at your zsh prompt you're engaging the zsh
line editing module, often abbreviated zle
. The zle
module lets you
do fancy things like delete the entire line you're working on and
move by words instead of individual characters. The zle
module operates
in two modes:
# Emacs mode
bindkey -e
# Vi mode
bindkey -v
Emacs mode is the default. Gross. Let's change it to Vi.
Vi Mode
Engaging Vi mode is easy. As previously mentioned, all you need is this line:
bindkey -v
This allows you to press <ESC>
to switch to NORMAL
mode.
Predictably, this lets you move around the line in standard Vim fashion.
You can then use any of the standard vim insert keystrokes to move back
to insert mode.
The default setup will you get pretty far, but if you use it frequently you will quickly realize it has a few shortcomings. First, there is noticeable lag when moving between modes. Second, there is no visual indication of which mode you're currently in. We will address these shortcomings in the next two sections.
Kill The Lag
By default, there is a 0.4 second delay after you hit the
<ESC>
key and when the mode change is registered. This results
in a very jarring and frustrating transition between modes. Let's
reduce this delay to 0.1 seconds.
export KEYTIMEOUT=1
This can result in issues with other terminal commands that depended on this delay. If you have issues try raising the delay.
Visual Indication
This is the second and far more frustrating problem. If you can't definitely
tell which mode you're in, you feel much less confident when moving around
Vi mode. To fix this problem, we need to take advantage of a few special features
of zle
.
zle
provides an interface for extension and customization through widgets. In
addition to being able to define your own widgets, zle
comes with quite a few
handy built-in widgets that we can hijack to accomplish what we want.
The first is zle-line-init
. Per the zsh documentation:
Executed every time the line editor is started to read a new line of input.
The second is zle-keymap-select
. Per the zsh documentation:
Executed every time the keymap changes, i.e. the special parameter KEYMAP is set to a different value, while the line editor is active. Initialising the keymap when the line editor starts does not cause the widget to be called. This can be used for detecting switches between the vi command (vicmd) and insert (usually main) keymaps.
Sounds perfect, if we can modify the code run during these two events, we have found the hooks we need to modify our prompt depending on which mode we're in!
The syntax for adding custom a function to a zle
widget looks like this:
something() {
zle backward-word
}
zle -N something
This has just added a brand new widget to zsh that executes something()
when called.
But this widget won't ever get run, since the something()
widget doesn't get called
by default. Using the widgets from above, we can rewrite the prompt every time we
leave or enter the different Vim modes.
Color Prompt Function
Currently, my prompt looks something like this:
[~/code]: [master]
The current directory is on the left and my current git branch is on the right.
I want to add a [NORMAL]
status message to the right prompt when I'm in command
mode for vim so that it looks like this:
[~/code]: [NORMAL] [master]
So let's take a look at our actual function that updates my prompt. I'll present it in full here first, and then step through it slowly and explain more.
function zle-line-init zle-keymap-select {
VIM_PROMPT="%{$fg_bold[yellow]%} [% NORMAL]% %{$reset_color%}"
RPS1="${${KEYMAP/vicmd/$VIM_PROMPT}/(main|viins)/} $(git_custom_status) $EPS1"
zle reset-prompt
}
Let's look at the VIM_PROMPT
variable. If you've never messed with zsh colors, take
note that the %{fg_bold[yellow]%}
snippet sets the text color of everything that
comes after it to yellow. So, the [% NORMAL]%
bit outputs [NORMAL]
in yellow. The
%
symbols are used to escape the brackets. Finally, we'll have to end with %{$reset_color%}
to stop outputting yellow.
Next, we have to put this snippet in the right prompt depending on the current vim mode.
To understand the next line, you'll need to know about zsh parameter expansion. Basically,
it's just ${VARIABLE/PATTERN/REPLACEMENT}
. If the VARIABLE
matches the PATTERN
,
replace it with REPLACEMENT
. The line we're looking at is this:
RPS1="${${KEYMAP/vicmd/$VIM_PROMPT}/(main|viins)/}$(git_custom_status) $EPS1"
In this case, we use a double parameter expansion. The first replaces the expansion
of KEYMAP
(the current vim mode) with our yellow [NORMAL]
prompt if KEYMAP
is
currently set to vimcmd
(command mode). But, what if KEYMAP
isn't set to vicmd
?
Then the KEYMAP
expansion won't be set to $VIM_PROMPT
, in which case it will be
either main
or viins
. The last half of the expansion replaces either of those
strings with nothing, so we don't add the yellow [NORMAL]
string to our prompt.
Perfect.
Finally, we run:
zle reset-prompt
To redraw the current prompt.
Attach The Widgets
We have a working function, but how do we register with zle
? You'll notice our
function is named zle-line-init
and zle-keymap-select
. As previously discussed, these
are also the names of two important widgets that get triggered when moving between Vim modes.
So, to make our new widget respond to the correct Vim mode we have to add these widgets to
the zle
module. This is easy, the lines are:
zle -N zle-line-init
zle -N zle-keymap-select
Common Key Bindings
As awesome as vim mode is, you might still miss some bindings that are standard
in most shells. For example, <Ctrl-P>
to cycle backwards through previous
commands. As you might assume, zle
lets you create custom bindings too. Here
are a few that I've found useful.
# Use vim cli mode
bindkey '^P' up-history
bindkey '^N' down-history
# backspace and ^h working even after
# returning from command mode
bindkey '^?' backward-delete-char
bindkey '^h' backward-delete-char
# ctrl-w removed word backwards
bindkey '^w' backward-kill-word
# ctrl-r starts searching history backward
bindkey '^r' history-incremental-search-backward
Full Snippet
Here's the full snippet:
bindkey -v
bindkey '^P' up-history
bindkey '^N' down-history
bindkey '^?' backward-delete-char
bindkey '^h' backward-delete-char
bindkey '^w' backward-kill-word
bindkey '^r' history-incremental-search-backward
function zle-line-init zle-keymap-select {
VIM_PROMPT="%{$fg_bold[yellow]%} [% NORMAL]% %{$reset_color%}"
RPS1="${${KEYMAP/vicmd/$VIM_PROMPT}/(main|viins)/}$(git_custom_status) $EPS1"
zle reset-prompt
}
zle -N zle-line-init
zle -N zle-keymap-select
export KEYTIMEOUT=1
This goes in your .zshrc
file.
Wrapping It Up
That's pretty much it. You should now have a more informative, prettier prompt and know
a little more about how zle
works!
Update: From the short discussion of this article on lobsters
I was tipped off to the existence of opp.zsh, a wonderful
plugin for zsh that makes vi mode even better! This enables text objects—a wonderful
magical feature added in Vim. Text object support lets you run commands like ciw
(for change
inner word) that make line editing even simpler. I highly recommend checking out opp.zsh for an
even better vi-mode in zsh.
Questions? @dougblackio