In a previous post, I alluded to the possibility of uniting Vim and Emacs in a shell, and this is that post! For those of you who want to have the flexibility of having both Vim and Emacs keybindings in your shell environment, read on for how to set this up.

In this post, we will incrementally build up a complete ~/.zshrc config that will support both Vim and Emacs keybindings, which means we’ll have a dual-mode command-line environment, just like the Vim editor, with both insert and normal (command) mode. Emacs keybindings will work in insert mode, since it’s not a modal editor.

Let’s start by initializing our command-line prompt. We will, of course, need to enable variable substitution:

setopt prompt_subst

We will begin in Vim’s INSERT mode, which matches Emacs-style command-line editing:

VIMODE=" I"

The leading space here is intentional: it will separate the mode character from the text coming before it. The normal or command mode will be " C", similarly with a leading space. You can, of course, choose " N" for normal mode, if you wish, by modifying the code below appropriately.

Next, let’s define our left-hand side prompt template:

readonly PS1_TEMPLATE='%B%n%b@%U%m%u${VIMODE}%(!.#.>) '

This string is in single quotes to avoid early evaluation; we will periodically re-evaluate this string, at which time we will need to capture the then-current value of $VIMODE.

This will display as follows:

user@hostname I>           # insert mode
user@hostname C>           # command mode

Let’s evaluate the current value of $PS1_TEMPLATE in the current environment, i.e., where $VIMODE is " I", and store it to initialize the prompt:

PS1="${PS1_TEMPLATE}"

This next part is optional and orthogonal to the Vim+Emacs config, but keeps the left-hand side at a fixed length by putting the current path on the right side of the prompt. Here, we keep at most 5 dirs on the right prompt, collapsing $HOME to ~:

RPS1="%5~"

Let’s enable Vim-style keybindings with some standard Emacs-style control keys that you might be used to in a standard shell environment:

bindkey -v
bindkey "^A" beginning-of-line
bindkey "^E" end-of-line
bindkey "^K" kill-line
bindkey "^L" clear-screen
bindkey "^R" history-incremental-search-backward
bindkey "^U" kill-whole-line
bindkey "^W" backward-kill-word
bindkey "^Y" yank

Let’s also tell Zsh to intialize line editing in Vim insert mode. Note that it must match the $VIMODE initial setting above, which is " I":

function zle-line-init() {
  zle -K viins
}
zle -N zle-line-init

Show the mode in the prompt

Now, let’s define how mode switches should update our prompt. Here, we are using short mode line (command vs. insert mode) on the left-hand side. If you prefer having the full mode text here, feel free to substitute C with NORMAL and I with INSERT — keeping their lengths equal ensures that your prompt doesn’t shift left/right when changing modes:

function zle-keymap-select {
  VIMODE="${${KEYMAP/vicmd/ C}/(main|viins)/ I}"
  # Re-evaluate $PS1_TEMPLATE with the updated value of $VIMODE.
  PS1="${PS1_TEMPLATE}"
  zle reset-prompt
}
zle -N zle-keymap-select

Note that we are not modifying $RPS1 here (which holds our path) because it’s independent of the insert vs. normal (command) mode in this case.

Open full editor for complex commands.

Another optional feature here is to enable richer command-line editing for much longer or complex commands is to press v to open your text editor (defined in $EDITOR) to edit your command line. Simply save and exit in the editor to update your command line in-place.

autoload -U edit-command-line
zle -N edit-command-line
bindkey -M vicmd v edit-command-line

Putting it all together

Let’s combine the snippets of code we’ve worked out above to get our final solution:

# Copyright 2019 Misha Brukman
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

setopt prompt_subst
VIMODE=" I"

# This string is in single quotes to avoid early evaluation; we will
# periodically re-evaluate this string, at which time we will need to
# capture then-current value of $VIMODE.
readonly REAL_PS1='%B%n%b@%U%m%u${VIMODE}%(!.#.>) '
PS1="${REAL_PS1}"

# Show current path with at most 5 dirs on the right prompt, collapsing
# $HOME as `~`.
RPS1="%5~"

# Vi with some Emacs flavor control keys.
bindkey -v
bindkey "^A" beginning-of-line
bindkey "^E" end-of-line
bindkey "^K" kill-line
bindkey "^L" clear-screen
bindkey "^R" history-incremental-search-backward
bindkey "^U" kill-whole-line
bindkey "^W" backward-kill-word
bindkey "^Y" yank

function zle-line-init() {
  # Note: this initial mode must match the $VIMODE initial value above.
  zle -K viins
}

zle -N zle-line-init

# Show insert/command mode in vi.
# zle-keymap-select is executed every time KEYMAP changes.
function zle-keymap-select {
  VIMODE="${${KEYMAP/vicmd/ C}/(main|viins)/ I}"
  PS1="${REAL_PS1}"
  zle reset-prompt
}

zle -N zle-keymap-select

# 'v' in visual mode opens VIM to edit the command in a full editor.
autoload -U edit-command-line
zle -N edit-command-line
bindkey -M vicmd v edit-command-line